feat(service-party-app): 添加SQLite存储和Kava区块链集成
## 主要变更 ### 1. SQLite 本地存储 (sql.js) - 使用 sql.js (纯 JavaScript SQLite) 替代 better-sqlite3 - 无需本地数据库服务,跨平台兼容 - 表结构: shares, derived_addresses, signing_history, settings - AES-256-GCM 加密 share 数据,PBKDF2 密钥派生 ### 2. Kava 区块链集成 - 新增 kava-tx-service.ts: REST API 交易服务 - 余额查询 (ukava/KAVA) - 交易构建和广播 - 交易状态查询 - 支持多个备用端点自动切换 ### 3. 地址派生 - 新增 address-derivation.ts: 多链地址派生 - 支持 Kava, Cosmos, Osmosis, Ethereum 等链 - 使用 Node.js crypto 替代 @noble/hashes 以解决模块兼容问题 - 手动实现 secp256k1 公钥解压缩 ### 4. IPC 处理器 - main.ts: 添加 Kava 相关 IPC 处理器 - preload.ts: 暴露 kava API 给渲染进程 - electron.d.ts: 完整的 TypeScript 类型定义 ## 新增文件 - electron/modules/database.ts - electron/modules/address-derivation.ts - electron/modules/kava-client.ts - electron/modules/kava-tx-service.ts - electron/types/sql.js.d.ts - src/utils/address.ts - .gitignore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
76ef8b0a8c
commit
c97cd208ab
|
|
@ -474,7 +474,10 @@
|
||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复共管钱包 status 类型不匹配问题\n\n使用 Prisma 生成的类型替代手动定义的接口:\n- PrismaCoManagedWalletSession -> @prisma/client\n- PrismaCoManagedWallet -> @prisma/client\n- status 字段使用 PrismaWalletSessionStatus 枚举类型\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复共管钱包 status 类型不匹配问题\n\n使用 Prisma 生成的类型替代手动定义的接口:\n- PrismaCoManagedWalletSession -> @prisma/client\n- PrismaCoManagedWallet -> @prisma/client\n- status 字段使用 PrismaWalletSessionStatus 枚举类型\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\ndocs: 添加 Service Party App 技术文档\n\n添加分布式共管钱包桌面应用的详细技术文档,包括:\n\n- 应用概述和使用场景\n- 目录结构说明\n- 技术架构和技术栈\n- TSS 子进程架构设计\n- IPC 消息格式定义\n- 核心功能说明\n- 编译与运行指南\n- 安全考虑\n- 系统集成说明\n- 未来扩展规划\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git commit -m \"$\\(cat <<''EOF''\ndocs: 添加 Service Party App 技术文档\n\n添加分布式共管钱包桌面应用的详细技术文档,包括:\n\n- 应用概述和使用场景\n- 目录结构说明\n- 技术架构和技术栈\n- TSS 子进程架构设计\n- IPC 消息格式定义\n- 核心功能说明\n- 编译与运行指南\n- 安全考虑\n- 系统集成说明\n- 未来扩展规划\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 修复系统维护\"立即激活\"按钮不显示的问题\n\n- 修复 getStatusTag 函数逻辑,未激活状态使用 ''inactive'' 样式而不是 ''expired''\n- 添加更细化的状态判断:维护中、已过期、已计划、未激活、待激活\n- 添加 inactive 标签样式(橙色背景)\n- 现在未激活的维护计划会正确显示\"立即激活\"按钮\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 修复系统维护\"立即激活\"按钮不显示的问题\n\n- 修复 getStatusTag 函数逻辑,未激活状态使用 ''inactive'' 样式而不是 ''expired''\n- 添加更细化的状态判断:维护中、已过期、已计划、未激活、待激活\n- 添加 inactive 标签样式(橙色背景)\n- 现在未激活的维护计划会正确显示\"立即激活\"按钮\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(migration\\): 使数据库迁移脚本幂等化,支持重复执行\n\n将 008_add_co_managed_wallet_fields.up.sql 改为幂等脚本:\n- 使用 DO $$... IF NOT EXISTS 检查列是否存在再添加\n- 使用 CREATE INDEX IF NOT EXISTS 创建索引\n- 使用 DROP CONSTRAINT IF EXISTS 删除约束\n\n这确保迁移脚本可以安全地多次执行,不会因列/索引已存在而失败。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(migration\\): 使数据库迁移脚本幂等化,支持重复执行\n\n将 008_add_co_managed_wallet_fields.up.sql 改为幂等脚本:\n- 使用 DO $$... IF NOT EXISTS 检查列是否存在再添加\n- 使用 CREATE INDEX IF NOT EXISTS 创建索引\n- 使用 DROP CONSTRAINT IF EXISTS 删除约束\n\n这确保迁移脚本可以安全地多次执行,不会因列/索引已存在而失败。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(service-party-app\\): 添加 Windows 一键编译脚本\n\n添加 build-windows.bat 脚本,支持:\n- 检查 Node.js 和 Go 环境\n- 编译 TSS 子进程 \\(tss-party.exe\\)\n- 安装 npm 依赖\n- 编译 Electron 应用\n\n使用方法: 双击运行 build-windows.bat\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||||
|
"Bash(./node_modules/.bin/tsc:*)",
|
||||||
|
"Bash(npm ls:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Electron build cache
|
||||||
|
.electron/
|
||||||
|
|
@ -3,15 +3,18 @@ import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { GrpcClient } from './modules/grpc-client';
|
import { GrpcClient } from './modules/grpc-client';
|
||||||
import { SecureStorage } from './modules/storage';
|
import { DatabaseManager } from './modules/database';
|
||||||
|
import { addressDerivationService, CHAIN_CONFIGS } from './modules/address-derivation';
|
||||||
|
import { KavaTxService, KAVA_MAINNET_TX_CONFIG } from './modules/kava-tx-service';
|
||||||
|
|
||||||
// 内置 HTTP 服务器端口
|
// 内置 HTTP 服务器端口
|
||||||
const HTTP_PORT = 3456;
|
const HTTP_PORT = 3456;
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let grpcClient: GrpcClient | null = null;
|
let grpcClient: GrpcClient | null = null;
|
||||||
let storage: SecureStorage | null = null;
|
let database: DatabaseManager | null = null;
|
||||||
let httpServer: ReturnType<typeof express.application.listen> | null = null;
|
let httpServer: ReturnType<typeof express.application.listen> | null = null;
|
||||||
|
let kavaTxService: KavaTxService | null = null;
|
||||||
|
|
||||||
// 创建主窗口
|
// 创建主窗口
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
|
|
@ -83,8 +86,11 @@ async function initServices() {
|
||||||
// 初始化 gRPC 客户端
|
// 初始化 gRPC 客户端
|
||||||
grpcClient = new GrpcClient();
|
grpcClient = new GrpcClient();
|
||||||
|
|
||||||
// 初始化安全存储
|
// 初始化数据库
|
||||||
storage = new SecureStorage();
|
database = new DatabaseManager();
|
||||||
|
|
||||||
|
// 初始化 Kava 交易服务
|
||||||
|
kavaTxService = new KavaTxService(KAVA_MAINNET_TX_CONFIG);
|
||||||
|
|
||||||
// 设置 IPC 处理器
|
// 设置 IPC 处理器
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
|
|
@ -92,6 +98,10 @@ async function initServices() {
|
||||||
|
|
||||||
// 设置 IPC 通信处理器
|
// 设置 IPC 通信处理器
|
||||||
function setupIpcHandlers() {
|
function setupIpcHandlers() {
|
||||||
|
// ===========================================================================
|
||||||
|
// gRPC 相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
// gRPC 连接
|
// gRPC 连接
|
||||||
ipcMain.handle('grpc:connect', async (_event, { host, port }) => {
|
ipcMain.handle('grpc:connect', async (_event, { host, port }) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -122,100 +132,20 @@ function setupIpcHandlers() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 存储 - 保存 share
|
|
||||||
ipcMain.handle('storage:saveShare', async (_event, { share, password }) => {
|
|
||||||
try {
|
|
||||||
storage?.saveShare(share, password);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 获取 share 列表
|
|
||||||
ipcMain.handle('storage:listShares', async () => {
|
|
||||||
try {
|
|
||||||
const shares = storage?.listShares() ?? [];
|
|
||||||
return { success: true, data: shares };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 导出 share
|
|
||||||
ipcMain.handle('storage:exportShare', async (_event, { id, password }) => {
|
|
||||||
try {
|
|
||||||
const data = storage?.exportShare(id, password);
|
|
||||||
return { success: true, data };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 导入 share (从文件路径)
|
|
||||||
ipcMain.handle('storage:importShare', async (_event, { filePath, password }) => {
|
|
||||||
try {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const share = storage?.importShare(data, password);
|
|
||||||
return { success: true, share };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 获取单个 share
|
|
||||||
ipcMain.handle('storage:getShare', async (_event, { id, password }) => {
|
|
||||||
try {
|
|
||||||
const share = storage?.getShare(id, password);
|
|
||||||
return share;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 删除 share
|
|
||||||
ipcMain.handle('storage:deleteShare', async (_event, { id }) => {
|
|
||||||
try {
|
|
||||||
storage?.deleteShare(id);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 获取设置
|
|
||||||
ipcMain.handle('storage:getSettings', async () => {
|
|
||||||
try {
|
|
||||||
return storage?.getSettings() ?? null;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 存储 - 保存设置
|
|
||||||
ipcMain.handle('storage:saveSettings', async (_event, { settings }) => {
|
|
||||||
try {
|
|
||||||
storage?.saveSettings(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: (error as Error).message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// gRPC - 创建会话
|
// gRPC - 创建会话
|
||||||
ipcMain.handle('grpc:createSession', async (_event, params) => {
|
ipcMain.handle('grpc:createSession', async (_event, _params) => {
|
||||||
// TODO: 实现创建会话逻辑
|
// TODO: 实现创建会话逻辑
|
||||||
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// gRPC - 验证邀请码
|
// gRPC - 验证邀请码
|
||||||
ipcMain.handle('grpc:validateInviteCode', async (_event, { code }) => {
|
ipcMain.handle('grpc:validateInviteCode', async (_event, { code: _code }) => {
|
||||||
// TODO: 实现验证邀请码逻辑
|
// TODO: 实现验证邀请码逻辑
|
||||||
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// gRPC - 获取会话状态
|
// gRPC - 获取会话状态
|
||||||
ipcMain.handle('grpc:getSessionStatus', async (_event, { sessionId }) => {
|
ipcMain.handle('grpc:getSessionStatus', async (_event, { sessionId: _sessionId }) => {
|
||||||
// TODO: 实现获取会话状态逻辑
|
// TODO: 实现获取会话状态逻辑
|
||||||
return { success: false, error: '功能尚未实现' };
|
return { success: false, error: '功能尚未实现' };
|
||||||
});
|
});
|
||||||
|
|
@ -232,18 +162,385 @@ function setupIpcHandlers() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// gRPC - 验证签名会话
|
// gRPC - 验证签名会话
|
||||||
ipcMain.handle('grpc:validateSigningSession', async (_event, { code }) => {
|
ipcMain.handle('grpc:validateSigningSession', async (_event, { code: _code }) => {
|
||||||
// TODO: 实现验证签名会话逻辑
|
// TODO: 实现验证签名会话逻辑
|
||||||
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// gRPC - 加入签名会话
|
// gRPC - 加入签名会话
|
||||||
ipcMain.handle('grpc:joinSigningSession', async (_event, params) => {
|
ipcMain.handle('grpc:joinSigningSession', async (_event, _params) => {
|
||||||
// TODO: 实现加入签名会话逻辑
|
// TODO: 实现加入签名会话逻辑
|
||||||
return { success: false, error: '功能尚未实现' };
|
return { success: false, error: '功能尚未实现' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对话框 - 选择目录
|
// ===========================================================================
|
||||||
|
// Share 存储相关 (SQLite)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 保存 share
|
||||||
|
ipcMain.handle('storage:saveShare', async (_event, { share, password }) => {
|
||||||
|
try {
|
||||||
|
const saved = database?.saveShare({
|
||||||
|
sessionId: share.sessionId,
|
||||||
|
walletName: share.walletName,
|
||||||
|
partyId: share.partyId,
|
||||||
|
partyIndex: share.partyIndex,
|
||||||
|
thresholdT: share.threshold.t,
|
||||||
|
thresholdN: share.threshold.n,
|
||||||
|
publicKeyHex: share.publicKey,
|
||||||
|
rawShare: share.rawShare,
|
||||||
|
participants: share.participants || [],
|
||||||
|
}, password);
|
||||||
|
|
||||||
|
// 自动派生 Kava 地址
|
||||||
|
if (saved && share.publicKey) {
|
||||||
|
try {
|
||||||
|
const kavaAddress = addressDerivationService.deriveAddress(share.publicKey, 'kava');
|
||||||
|
database?.saveDerivedAddress({
|
||||||
|
shareId: saved.id,
|
||||||
|
chain: 'kava',
|
||||||
|
derivationPath: kavaAddress.derivationPath,
|
||||||
|
address: kavaAddress.address,
|
||||||
|
publicKeyHex: share.publicKey,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to derive Kava address:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: saved };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 share 列表
|
||||||
|
ipcMain.handle('storage:listShares', async () => {
|
||||||
|
try {
|
||||||
|
const shares = database?.listShares() ?? [];
|
||||||
|
// 转换为前端期望的格式
|
||||||
|
const formatted = shares.map(share => ({
|
||||||
|
id: share.id,
|
||||||
|
sessionId: share.session_id,
|
||||||
|
walletName: share.wallet_name,
|
||||||
|
partyId: share.party_id,
|
||||||
|
partyIndex: share.party_index,
|
||||||
|
threshold: {
|
||||||
|
t: share.threshold_t,
|
||||||
|
n: share.threshold_n,
|
||||||
|
},
|
||||||
|
publicKey: share.public_key_hex,
|
||||||
|
createdAt: share.created_at,
|
||||||
|
lastUsedAt: share.last_used_at,
|
||||||
|
participants: JSON.parse(share.participants_json || '[]'),
|
||||||
|
}));
|
||||||
|
return { success: true, data: formatted };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取单个 share (解密)
|
||||||
|
ipcMain.handle('storage:getShare', async (_event, { id, password }) => {
|
||||||
|
try {
|
||||||
|
const share = database?.getShare(id, password);
|
||||||
|
if (!share) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: share.id,
|
||||||
|
sessionId: share.session_id,
|
||||||
|
walletName: share.wallet_name,
|
||||||
|
partyId: share.party_id,
|
||||||
|
partyIndex: share.party_index,
|
||||||
|
threshold: {
|
||||||
|
t: share.threshold_t,
|
||||||
|
n: share.threshold_n,
|
||||||
|
},
|
||||||
|
publicKey: share.public_key_hex,
|
||||||
|
rawShare: share.raw_share,
|
||||||
|
createdAt: share.created_at,
|
||||||
|
lastUsedAt: share.last_used_at,
|
||||||
|
participants: JSON.parse(share.participants_json || '[]'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 share
|
||||||
|
ipcMain.handle('storage:deleteShare', async (_event, { id }) => {
|
||||||
|
try {
|
||||||
|
database?.deleteShare(id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出 share
|
||||||
|
ipcMain.handle('storage:exportShare', async (_event, { id, password }) => {
|
||||||
|
try {
|
||||||
|
const data = database?.exportShare(id, password);
|
||||||
|
return { success: true, data };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导入 share
|
||||||
|
ipcMain.handle('storage:importShare', async (_event, { filePath, password }) => {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
const share = database?.importShare(data, password);
|
||||||
|
return { success: true, share };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 地址派生相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 派生地址
|
||||||
|
ipcMain.handle('address:derive', async (_event, { shareId, chain, password }) => {
|
||||||
|
try {
|
||||||
|
const share = database?.getShare(shareId, password);
|
||||||
|
if (!share) {
|
||||||
|
return { success: false, error: 'Share not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const derived = addressDerivationService.deriveAddress(share.public_key_hex, chain);
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const saved = database?.saveDerivedAddress({
|
||||||
|
shareId,
|
||||||
|
chain,
|
||||||
|
derivationPath: derived.derivationPath,
|
||||||
|
address: derived.address,
|
||||||
|
publicKeyHex: share.public_key_hex,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { ...derived, id: saved?.id } };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 派生所有支持的链地址
|
||||||
|
ipcMain.handle('address:deriveAll', async (_event, { shareId, password }) => {
|
||||||
|
try {
|
||||||
|
const share = database?.getShare(shareId, password);
|
||||||
|
if (!share) {
|
||||||
|
return { success: false, error: 'Share not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const addresses = addressDerivationService.deriveAllAddresses(share.public_key_hex);
|
||||||
|
|
||||||
|
// 保存所有地址
|
||||||
|
for (const addr of addresses) {
|
||||||
|
database?.saveDerivedAddress({
|
||||||
|
shareId,
|
||||||
|
chain: addr.chain,
|
||||||
|
derivationPath: addr.derivationPath,
|
||||||
|
address: addr.address,
|
||||||
|
publicKeyHex: share.public_key_hex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: addresses };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 share 的所有派生地址
|
||||||
|
ipcMain.handle('address:list', async (_event, { shareId }) => {
|
||||||
|
try {
|
||||||
|
const addresses = database?.getAddressesByShare(shareId) ?? [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: addresses.map(addr => ({
|
||||||
|
id: addr.id,
|
||||||
|
shareId: addr.share_id,
|
||||||
|
chain: addr.chain,
|
||||||
|
chainName: CHAIN_CONFIGS[addr.chain]?.name || addr.chain,
|
||||||
|
derivationPath: addr.derivation_path,
|
||||||
|
address: addr.address,
|
||||||
|
publicKeyHex: addr.public_key_hex,
|
||||||
|
createdAt: addr.created_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取支持的链列表
|
||||||
|
ipcMain.handle('address:getSupportedChains', async () => {
|
||||||
|
return addressDerivationService.getSupportedChains();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 签名历史相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 获取签名历史
|
||||||
|
ipcMain.handle('signing:getHistory', async (_event, { shareId }) => {
|
||||||
|
try {
|
||||||
|
const history = database?.getSigningHistoryByShare(shareId) ?? [];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: history.map(h => ({
|
||||||
|
id: h.id,
|
||||||
|
shareId: h.share_id,
|
||||||
|
sessionId: h.session_id,
|
||||||
|
messageHash: h.message_hash,
|
||||||
|
signature: h.signature,
|
||||||
|
status: h.status,
|
||||||
|
errorMessage: h.error_message,
|
||||||
|
createdAt: h.created_at,
|
||||||
|
completedAt: h.completed_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 设置相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 获取设置
|
||||||
|
ipcMain.handle('storage:getSettings', async () => {
|
||||||
|
try {
|
||||||
|
const settings = database?.getAllSettings() ?? {};
|
||||||
|
return {
|
||||||
|
messageRouterUrl: settings['message_router_url'] || 'mpc-grpc.szaiai.com:443',
|
||||||
|
autoBackup: settings['auto_backup'] === 'true',
|
||||||
|
backupPath: settings['backup_path'] || '',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
|
ipcMain.handle('storage:saveSettings', async (_event, { settings }) => {
|
||||||
|
try {
|
||||||
|
if (settings.messageRouterUrl !== undefined) {
|
||||||
|
database?.setSetting('message_router_url', settings.messageRouterUrl);
|
||||||
|
}
|
||||||
|
if (settings.autoBackup !== undefined) {
|
||||||
|
database?.setSetting('auto_backup', settings.autoBackup ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (settings.backupPath !== undefined) {
|
||||||
|
database?.setSetting('backup_path', settings.backupPath);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Kava 区块链相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 查询 Kava 余额
|
||||||
|
ipcMain.handle('kava:getBalance', async (_event, { address }) => {
|
||||||
|
try {
|
||||||
|
const balance = await kavaTxService?.getKavaBalance(address);
|
||||||
|
return { success: true, data: balance };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询所有余额
|
||||||
|
ipcMain.handle('kava:getAllBalances', async (_event, { address }) => {
|
||||||
|
try {
|
||||||
|
const balances = await kavaTxService?.getAllBalances(address);
|
||||||
|
return { success: true, data: balances };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询账户信息
|
||||||
|
ipcMain.handle('kava:getAccountInfo', async (_event, { address }) => {
|
||||||
|
try {
|
||||||
|
const info = await kavaTxService?.getAccountInfo(address);
|
||||||
|
return { success: true, data: info };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建转账交易 (待签名)
|
||||||
|
ipcMain.handle('kava:buildSendTx', async (_event, { fromAddress, toAddress, amount, publicKeyHex, memo }) => {
|
||||||
|
try {
|
||||||
|
const unsignedTx = await kavaTxService?.buildSendTx(
|
||||||
|
fromAddress,
|
||||||
|
toAddress,
|
||||||
|
amount,
|
||||||
|
publicKeyHex,
|
||||||
|
memo || ''
|
||||||
|
);
|
||||||
|
return { success: true, data: unsignedTx };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 完成签名并广播交易
|
||||||
|
ipcMain.handle('kava:completeTxAndBroadcast', async (_event, { unsignedTx, signatureHex }) => {
|
||||||
|
try {
|
||||||
|
// 完成交易签名
|
||||||
|
const signedTx = await kavaTxService?.completeTx(unsignedTx, signatureHex);
|
||||||
|
if (!signedTx) {
|
||||||
|
return { success: false, error: 'Failed to complete transaction' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播交易
|
||||||
|
const result = await kavaTxService?.broadcastTx(signedTx);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询交易状态
|
||||||
|
ipcMain.handle('kava:getTxStatus', async (_event, { txHash }) => {
|
||||||
|
try {
|
||||||
|
const status = await kavaTxService?.getTxStatus(txHash);
|
||||||
|
return { success: true, data: status };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 Kava 配置
|
||||||
|
ipcMain.handle('kava:getConfig', async () => {
|
||||||
|
return kavaTxService?.getConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新 Kava 配置
|
||||||
|
ipcMain.handle('kava:updateConfig', async (_event, { config }) => {
|
||||||
|
try {
|
||||||
|
kavaTxService?.updateConfig(config);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 对话框相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 选择目录
|
||||||
ipcMain.handle('dialog:selectDirectory', async () => {
|
ipcMain.handle('dialog:selectDirectory', async () => {
|
||||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||||
properties: ['openDirectory'],
|
properties: ['openDirectory'],
|
||||||
|
|
@ -251,7 +548,7 @@ function setupIpcHandlers() {
|
||||||
return result.canceled ? null : result.filePaths[0];
|
return result.canceled ? null : result.filePaths[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对话框 - 选择文件
|
// 选择文件
|
||||||
ipcMain.handle('dialog:selectFile', async (_event, { filters }) => {
|
ipcMain.handle('dialog:selectFile', async (_event, { filters }) => {
|
||||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
|
|
@ -260,7 +557,7 @@ function setupIpcHandlers() {
|
||||||
return result.canceled ? null : result.filePaths[0];
|
return result.canceled ? null : result.filePaths[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对话框 - 保存文件
|
// 保存文件
|
||||||
ipcMain.handle('dialog:saveFile', async (_event, { defaultPath, filters }) => {
|
ipcMain.handle('dialog:saveFile', async (_event, { defaultPath, filters }) => {
|
||||||
const result = await dialog.showSaveDialog(mainWindow!, {
|
const result = await dialog.showSaveDialog(mainWindow!, {
|
||||||
defaultPath,
|
defaultPath,
|
||||||
|
|
@ -295,5 +592,7 @@ app.on('window-all-closed', () => {
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
// 清理资源
|
// 清理资源
|
||||||
grpcClient?.disconnect();
|
grpcClient?.disconnect();
|
||||||
|
database?.close();
|
||||||
httpServer?.close();
|
httpServer?.close();
|
||||||
|
kavaTxService?.disconnect();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { bech32 } from 'bech32';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 链配置
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ChainConfig {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
coinType: number;
|
||||||
|
curve: 'secp256k1' | 'ed25519';
|
||||||
|
derivationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAIN_CONFIGS: Record<string, ChainConfig> = {
|
||||||
|
kava: {
|
||||||
|
name: 'Kava',
|
||||||
|
prefix: 'kava',
|
||||||
|
coinType: 459,
|
||||||
|
curve: 'secp256k1',
|
||||||
|
derivationPath: "m/44'/459'/0'/0/0",
|
||||||
|
},
|
||||||
|
cosmos: {
|
||||||
|
name: 'Cosmos Hub',
|
||||||
|
prefix: 'cosmos',
|
||||||
|
coinType: 118,
|
||||||
|
curve: 'secp256k1',
|
||||||
|
derivationPath: "m/44'/118'/0'/0/0",
|
||||||
|
},
|
||||||
|
osmosis: {
|
||||||
|
name: 'Osmosis',
|
||||||
|
prefix: 'osmo',
|
||||||
|
coinType: 118,
|
||||||
|
curve: 'secp256k1',
|
||||||
|
derivationPath: "m/44'/118'/0'/0/0",
|
||||||
|
},
|
||||||
|
ethereum: {
|
||||||
|
name: 'Ethereum',
|
||||||
|
prefix: '0x',
|
||||||
|
coinType: 60,
|
||||||
|
curve: 'secp256k1',
|
||||||
|
derivationPath: "m/44'/60'/0'/0/0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 地址派生工具
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从公钥派生 Bech32 地址 (Cosmos 系列)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 公钥 → SHA256 → RIPEMD160 → 20字节地址
|
||||||
|
* 2. 20字节地址 → Bech32 编码
|
||||||
|
*/
|
||||||
|
export function deriveCosmosAddress(publicKeyHex: string, prefix: string): string {
|
||||||
|
// 移除可能的 0x 前缀
|
||||||
|
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||||
|
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||||
|
|
||||||
|
// 对于 secp256k1,需要压缩公钥 (33 bytes)
|
||||||
|
// 如果是未压缩公钥 (65 bytes),需要先压缩
|
||||||
|
let compressedKey: Buffer = publicKeyBytes;
|
||||||
|
if (publicKeyBytes.length === 65) {
|
||||||
|
compressedKey = compressSecp256k1PublicKey(publicKeyBytes);
|
||||||
|
} else if (publicKeyBytes.length === 64) {
|
||||||
|
// 没有前缀的未压缩公钥
|
||||||
|
const uncompressed = Buffer.concat([Buffer.from([0x04]), publicKeyBytes]);
|
||||||
|
compressedKey = compressSecp256k1PublicKey(uncompressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256 → RIPEMD160
|
||||||
|
const sha256Hash = crypto.createHash('sha256').update(compressedKey).digest();
|
||||||
|
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
|
||||||
|
|
||||||
|
// Bech32 编码
|
||||||
|
const words = bech32.toWords(ripemd160Hash);
|
||||||
|
const address = bech32.encode(prefix, words);
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从公钥派生以太坊地址
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 未压缩公钥 (去掉 04 前缀) → Keccak256 → 取后 20 字节
|
||||||
|
*/
|
||||||
|
export function deriveEthereumAddress(publicKeyHex: string): string {
|
||||||
|
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||||
|
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||||
|
|
||||||
|
// 需要未压缩公钥的 x, y 坐标 (64 bytes)
|
||||||
|
let uncompressedKey: Buffer;
|
||||||
|
if (publicKeyBytes.length === 33) {
|
||||||
|
// 压缩公钥,需要解压
|
||||||
|
uncompressedKey = decompressSecp256k1PublicKey(publicKeyBytes);
|
||||||
|
} else if (publicKeyBytes.length === 65) {
|
||||||
|
// 未压缩公钥,去掉 04 前缀
|
||||||
|
uncompressedKey = publicKeyBytes.slice(1) as Buffer;
|
||||||
|
} else if (publicKeyBytes.length === 64) {
|
||||||
|
uncompressedKey = publicKeyBytes;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid public key length: ${publicKeyBytes.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keccak256 (使用 keccak256 而不是 sha3-256)
|
||||||
|
const { keccak_256 } = require('@noble/hashes/sha3');
|
||||||
|
const hash = keccak_256(uncompressedKey);
|
||||||
|
|
||||||
|
// 取后 20 字节
|
||||||
|
const addressBytes = hash.slice(-20);
|
||||||
|
const address = '0x' + Buffer.from(addressBytes).toString('hex');
|
||||||
|
|
||||||
|
return checksumAddress(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Ed25519 公钥派生地址 (用于某些链)
|
||||||
|
*/
|
||||||
|
export function deriveEd25519Address(publicKeyHex: string, prefix: string): string {
|
||||||
|
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
|
||||||
|
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
|
||||||
|
|
||||||
|
// SHA256 → RIPEMD160
|
||||||
|
const sha256Hash = crypto.createHash('sha256').update(publicKeyBytes).digest();
|
||||||
|
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
|
||||||
|
|
||||||
|
// Bech32 编码
|
||||||
|
const words = bech32.toWords(ripemd160Hash);
|
||||||
|
const address = bech32.encode(prefix, words);
|
||||||
|
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩 secp256k1 公钥
|
||||||
|
*/
|
||||||
|
function compressSecp256k1PublicKey(uncompressed: Buffer): Buffer {
|
||||||
|
if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) {
|
||||||
|
throw new Error('Invalid uncompressed public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = uncompressed.slice(1, 33);
|
||||||
|
const y = uncompressed.slice(33, 65);
|
||||||
|
|
||||||
|
// 判断 y 是奇数还是偶数
|
||||||
|
const prefix = y[31] % 2 === 0 ? 0x02 : 0x03;
|
||||||
|
|
||||||
|
return Buffer.concat([Buffer.from([prefix]), x]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解压缩 secp256k1 公钥
|
||||||
|
* 使用椭圆曲线数学: y² = x³ + 7 (mod p)
|
||||||
|
*/
|
||||||
|
function decompressSecp256k1PublicKey(compressed: Buffer): Buffer {
|
||||||
|
if (compressed.length !== 33) {
|
||||||
|
throw new Error('Invalid compressed public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// secp256k1 曲线参数
|
||||||
|
const p = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F');
|
||||||
|
|
||||||
|
const prefix = compressed[0];
|
||||||
|
const x = BigInt('0x' + compressed.slice(1).toString('hex'));
|
||||||
|
|
||||||
|
// 计算 y² = x³ + 7 (mod p)
|
||||||
|
const xCubed = modPow(x, 3n, p);
|
||||||
|
const ySquared = (xCubed + 7n) % p;
|
||||||
|
|
||||||
|
// 计算平方根 (p ≡ 3 mod 4, 所以 y = ySquared^((p+1)/4) mod p)
|
||||||
|
let y = modPow(ySquared, (p + 1n) / 4n, p);
|
||||||
|
|
||||||
|
// 根据前缀选择正确的 y 值
|
||||||
|
const isYOdd = y % 2n === 1n;
|
||||||
|
const shouldBeOdd = prefix === 0x03;
|
||||||
|
|
||||||
|
if (isYOdd !== shouldBeOdd) {
|
||||||
|
y = p - y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Buffer (64 bytes: x || y)
|
||||||
|
const xBuffer = Buffer.from(x.toString(16).padStart(64, '0'), 'hex');
|
||||||
|
const yBuffer = Buffer.from(y.toString(16).padStart(64, '0'), 'hex');
|
||||||
|
|
||||||
|
return Buffer.concat([xBuffer, yBuffer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模幂运算
|
||||||
|
*/
|
||||||
|
function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
|
||||||
|
let result = 1n;
|
||||||
|
base = base % modulus;
|
||||||
|
|
||||||
|
while (exponent > 0n) {
|
||||||
|
if (exponent % 2n === 1n) {
|
||||||
|
result = (result * base) % modulus;
|
||||||
|
}
|
||||||
|
exponent = exponent / 2n;
|
||||||
|
base = (base * base) % modulus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIP-55 校验和地址
|
||||||
|
*/
|
||||||
|
function checksumAddress(address: string): string {
|
||||||
|
const { keccak_256 } = require('@noble/hashes/sha3');
|
||||||
|
const addr = address.toLowerCase().replace('0x', '');
|
||||||
|
const hash = Buffer.from(keccak_256(Buffer.from(addr, 'utf8'))).toString('hex');
|
||||||
|
|
||||||
|
let result = '0x';
|
||||||
|
for (let i = 0; i < addr.length; i++) {
|
||||||
|
if (parseInt(hash[i], 16) >= 8) {
|
||||||
|
result += addr[i].toUpperCase();
|
||||||
|
} else {
|
||||||
|
result += addr[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 地址派生服务
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface DerivedAddress {
|
||||||
|
chain: string;
|
||||||
|
chainName: string;
|
||||||
|
prefix: string;
|
||||||
|
address: string;
|
||||||
|
derivationPath: string;
|
||||||
|
publicKeyHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地址派生服务
|
||||||
|
*
|
||||||
|
* 注意:TSS keygen 生成的是聚合公钥,不是派生公钥。
|
||||||
|
* 对于需要不同链的地址,我们直接使用聚合公钥派生地址,
|
||||||
|
* 而不是像 HD 钱包那样从种子派生。
|
||||||
|
*
|
||||||
|
* 这意味着:
|
||||||
|
* - 所有链使用相同的公钥
|
||||||
|
* - 不同链的地址只是编码方式不同
|
||||||
|
* - 这是 TSS 钱包的标准做法
|
||||||
|
*/
|
||||||
|
export class AddressDerivationService {
|
||||||
|
/**
|
||||||
|
* 从 TSS 聚合公钥派生指定链的地址
|
||||||
|
*/
|
||||||
|
deriveAddress(publicKeyHex: string, chain: string): DerivedAddress {
|
||||||
|
const config = CHAIN_CONFIGS[chain];
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Unsupported chain: ${chain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let address: string;
|
||||||
|
|
||||||
|
if (chain === 'ethereum') {
|
||||||
|
address = deriveEthereumAddress(publicKeyHex);
|
||||||
|
} else if (config.curve === 'ed25519') {
|
||||||
|
address = deriveEd25519Address(publicKeyHex, config.prefix);
|
||||||
|
} else {
|
||||||
|
// Cosmos 系列 (kava, cosmos, osmosis 等)
|
||||||
|
address = deriveCosmosAddress(publicKeyHex, config.prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chain,
|
||||||
|
chainName: config.name,
|
||||||
|
prefix: config.prefix,
|
||||||
|
address,
|
||||||
|
derivationPath: config.derivationPath,
|
||||||
|
publicKeyHex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派生所有支持的链地址
|
||||||
|
*/
|
||||||
|
deriveAllAddresses(publicKeyHex: string): DerivedAddress[] {
|
||||||
|
const addresses: DerivedAddress[] = [];
|
||||||
|
|
||||||
|
for (const chain of Object.keys(CHAIN_CONFIGS)) {
|
||||||
|
try {
|
||||||
|
const derived = this.deriveAddress(publicKeyHex, chain);
|
||||||
|
addresses.push(derived);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to derive ${chain} address:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证地址格式
|
||||||
|
*/
|
||||||
|
validateAddress(address: string, chain: string): boolean {
|
||||||
|
const config = CHAIN_CONFIGS[chain];
|
||||||
|
if (!config) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain === 'ethereum') {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = bech32.decode(address);
|
||||||
|
return decoded.prefix === config.prefix;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的链列表
|
||||||
|
*/
|
||||||
|
getSupportedChains(): ChainConfig[] {
|
||||||
|
return Object.values(CHAIN_CONFIGS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const addressDerivationService = new AddressDerivationService();
|
||||||
|
|
@ -0,0 +1,668 @@
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
import type { Database as SqlJsDatabase, SqlJsStatic } from 'sql.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 数据库路径
|
||||||
|
// =============================================================================
|
||||||
|
function getDatabasePath(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, 'service-party.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 加密配置
|
||||||
|
// =============================================================================
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
const SALT_LENGTH = 32;
|
||||||
|
const TAG_LENGTH = 16;
|
||||||
|
const ITERATIONS = 100000;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 数据类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ShareRecord {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
wallet_name: string;
|
||||||
|
party_id: string;
|
||||||
|
party_index: number;
|
||||||
|
threshold_t: number;
|
||||||
|
threshold_n: number;
|
||||||
|
public_key_hex: string;
|
||||||
|
encrypted_share: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
participants_json: string; // JSON 存储参与者列表
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DerivedAddressRecord {
|
||||||
|
id: string;
|
||||||
|
share_id: string;
|
||||||
|
chain: string;
|
||||||
|
derivation_path: string;
|
||||||
|
address: string;
|
||||||
|
public_key_hex: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
share_id: string;
|
||||||
|
session_id: string;
|
||||||
|
message_hash: string;
|
||||||
|
signature: string | null;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
error_message: string | null;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsRecord {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 数据库管理类 (使用 sql.js - 纯 JavaScript SQLite)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class DatabaseManager {
|
||||||
|
private db: SqlJsDatabase | null = null;
|
||||||
|
private SQL: SqlJsStatic | null = null;
|
||||||
|
private dbPath: string;
|
||||||
|
private initPromise: Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dbPath = getDatabasePath();
|
||||||
|
this.initPromise = this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化数据库
|
||||||
|
*/
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
// 初始化 sql.js (加载 WASM)
|
||||||
|
this.SQL = await initSqlJs();
|
||||||
|
|
||||||
|
// 如果数据库文件存在,加载它
|
||||||
|
if (fs.existsSync(this.dbPath)) {
|
||||||
|
const buffer = fs.readFileSync(this.dbPath);
|
||||||
|
this.db = new this.SQL.Database(buffer);
|
||||||
|
} else {
|
||||||
|
this.db = new this.SQL.Database();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建表结构
|
||||||
|
this.createTables();
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保数据库已初始化
|
||||||
|
*/
|
||||||
|
private async ensureReady(): Promise<void> {
|
||||||
|
await this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建表结构
|
||||||
|
*/
|
||||||
|
private createTables(): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
wallet_name TEXT NOT NULL,
|
||||||
|
party_id TEXT NOT NULL,
|
||||||
|
party_index INTEGER NOT NULL,
|
||||||
|
threshold_t INTEGER NOT NULL,
|
||||||
|
threshold_n INTEGER NOT NULL,
|
||||||
|
public_key_hex TEXT NOT NULL,
|
||||||
|
encrypted_share TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
participants_json TEXT NOT NULL DEFAULT '[]'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS derived_addresses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
chain TEXT NOT NULL,
|
||||||
|
derivation_path TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
public_key_hex TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(share_id, chain, derivation_path)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS signing_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
message_hash TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id)`);
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_share ON derived_addresses(share_id)`);
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_chain ON derived_addresses(chain)`);
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_share ON signing_history(share_id)`);
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_status ON signing_history(status)`);
|
||||||
|
|
||||||
|
// 插入默认设置
|
||||||
|
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['message_router_url', 'mpc-grpc.szaiai.com:443']);
|
||||||
|
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['auto_backup', 'false']);
|
||||||
|
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['backup_path', '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存数据库到文件
|
||||||
|
*/
|
||||||
|
private saveToFile(): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
const data = this.db.export();
|
||||||
|
const buffer = Buffer.from(data);
|
||||||
|
fs.writeFileSync(this.dbPath, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从密码派生密钥
|
||||||
|
*/
|
||||||
|
private deriveKey(password: string, salt: Buffer): Buffer {
|
||||||
|
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密数据
|
||||||
|
*/
|
||||||
|
private encrypt(data: string, password: string): string {
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
const key = this.deriveKey(password, salt);
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 格式: salt(hex) + iv(hex) + tag(hex) + encrypted(hex)
|
||||||
|
return salt.toString('hex') + iv.toString('hex') + tag.toString('hex') + encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密数据
|
||||||
|
*/
|
||||||
|
private decrypt(encryptedData: string, password: string): string {
|
||||||
|
const salt = Buffer.from(encryptedData.slice(0, SALT_LENGTH * 2), 'hex');
|
||||||
|
const iv = Buffer.from(encryptedData.slice(SALT_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2), 'hex');
|
||||||
|
const tag = Buffer.from(encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2), 'hex');
|
||||||
|
const encrypted = encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2);
|
||||||
|
|
||||||
|
const key = this.deriveKey(password, salt);
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将查询结果转换为对象数组
|
||||||
|
*/
|
||||||
|
private queryToObjects<T>(sql: string, params: unknown[] = []): T[] {
|
||||||
|
if (!this.db) return [];
|
||||||
|
|
||||||
|
const results = this.db.exec(sql, params);
|
||||||
|
if (results.length === 0) return [];
|
||||||
|
|
||||||
|
const columns = results[0].columns;
|
||||||
|
return results[0].values.map((row: (number | string | Uint8Array | null)[]) => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
columns.forEach((col: string, i: number) => {
|
||||||
|
obj[col] = row[i];
|
||||||
|
});
|
||||||
|
return obj as T;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个对象
|
||||||
|
*/
|
||||||
|
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
|
||||||
|
const results = this.queryToObjects<T>(sql, params);
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Share 操作
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 share
|
||||||
|
*/
|
||||||
|
saveShare(params: {
|
||||||
|
sessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
thresholdT: number;
|
||||||
|
thresholdN: number;
|
||||||
|
publicKeyHex: string;
|
||||||
|
rawShare: string;
|
||||||
|
participants: Array<{ partyId: string; name: string }>;
|
||||||
|
}, password: string): ShareRecord {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const encryptedShare = this.encrypt(params.rawShare, password);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
INSERT INTO shares (
|
||||||
|
id, session_id, wallet_name, party_id, party_index,
|
||||||
|
threshold_t, threshold_n, public_key_hex, encrypted_share,
|
||||||
|
created_at, participants_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
params.sessionId,
|
||||||
|
params.walletName,
|
||||||
|
params.partyId,
|
||||||
|
params.partyIndex,
|
||||||
|
params.thresholdT,
|
||||||
|
params.thresholdN,
|
||||||
|
params.publicKeyHex,
|
||||||
|
encryptedShare,
|
||||||
|
now,
|
||||||
|
JSON.stringify(params.participants)
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.saveToFile();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
session_id: params.sessionId,
|
||||||
|
wallet_name: params.walletName,
|
||||||
|
party_id: params.partyId,
|
||||||
|
party_index: params.partyIndex,
|
||||||
|
threshold_t: params.thresholdT,
|
||||||
|
threshold_n: params.thresholdN,
|
||||||
|
public_key_hex: params.publicKeyHex,
|
||||||
|
encrypted_share: encryptedShare,
|
||||||
|
created_at: now,
|
||||||
|
last_used_at: null,
|
||||||
|
participants_json: JSON.stringify(params.participants),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 share (不含加密数据)
|
||||||
|
*/
|
||||||
|
listShares(): Omit<ShareRecord, 'encrypted_share'>[] {
|
||||||
|
return this.queryToObjects<Omit<ShareRecord, 'encrypted_share'>>(`
|
||||||
|
SELECT id, session_id, wallet_name, party_id, party_index,
|
||||||
|
threshold_t, threshold_n, public_key_hex, created_at,
|
||||||
|
last_used_at, participants_json
|
||||||
|
FROM shares
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 share (解密)
|
||||||
|
*/
|
||||||
|
getShare(id: string, password: string): ShareRecord & { raw_share: string } {
|
||||||
|
const share = this.queryOne<ShareRecord>(`SELECT * FROM shares WHERE id = ?`, [id]);
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
throw new Error('Share not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawShare = this.decrypt(share.encrypted_share, password);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...share,
|
||||||
|
raw_share: rawShare,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 share 最后使用时间
|
||||||
|
*/
|
||||||
|
updateShareLastUsed(id: string): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
this.db.run(`UPDATE shares SET last_used_at = ? WHERE id = ?`, [new Date().toISOString(), id]);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 share (级联删除派生地址和签名历史)
|
||||||
|
*/
|
||||||
|
deleteShare(id: string): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
// 手动级联删除
|
||||||
|
this.db.run(`DELETE FROM derived_addresses WHERE share_id = ?`, [id]);
|
||||||
|
this.db.run(`DELETE FROM signing_history WHERE share_id = ?`, [id]);
|
||||||
|
this.db.run(`DELETE FROM shares WHERE id = ?`, [id]);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 派生地址操作
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存派生地址
|
||||||
|
*/
|
||||||
|
saveDerivedAddress(params: {
|
||||||
|
shareId: string;
|
||||||
|
chain: string;
|
||||||
|
derivationPath: string;
|
||||||
|
address: string;
|
||||||
|
publicKeyHex: string;
|
||||||
|
}): DerivedAddressRecord {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
INSERT OR REPLACE INTO derived_addresses (
|
||||||
|
id, share_id, chain, derivation_path, address, public_key_hex, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
params.shareId,
|
||||||
|
params.chain,
|
||||||
|
params.derivationPath,
|
||||||
|
params.address,
|
||||||
|
params.publicKeyHex,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.saveToFile();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
share_id: params.shareId,
|
||||||
|
chain: params.chain,
|
||||||
|
derivation_path: params.derivationPath,
|
||||||
|
address: params.address,
|
||||||
|
public_key_hex: params.publicKeyHex,
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 share 的所有派生地址
|
||||||
|
*/
|
||||||
|
getAddressesByShare(shareId: string): DerivedAddressRecord[] {
|
||||||
|
return this.queryToObjects<DerivedAddressRecord>(`
|
||||||
|
SELECT * FROM derived_addresses
|
||||||
|
WHERE share_id = ?
|
||||||
|
ORDER BY chain, derivation_path
|
||||||
|
`, [shareId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据链获取地址
|
||||||
|
*/
|
||||||
|
getAddressByChain(shareId: string, chain: string): DerivedAddressRecord | undefined {
|
||||||
|
return this.queryOne<DerivedAddressRecord>(`
|
||||||
|
SELECT * FROM derived_addresses
|
||||||
|
WHERE share_id = ? AND chain = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, [shareId, chain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有指定链的地址
|
||||||
|
*/
|
||||||
|
getAllAddressesByChain(chain: string): DerivedAddressRecord[] {
|
||||||
|
return this.queryToObjects<DerivedAddressRecord>(`
|
||||||
|
SELECT * FROM derived_addresses
|
||||||
|
WHERE chain = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [chain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 签名历史操作
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建签名历史记录
|
||||||
|
*/
|
||||||
|
createSigningHistory(params: {
|
||||||
|
shareId: string;
|
||||||
|
sessionId: string;
|
||||||
|
messageHash: string;
|
||||||
|
}): SigningHistoryRecord {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
INSERT INTO signing_history (
|
||||||
|
id, share_id, session_id, message_hash, status, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'pending', ?)
|
||||||
|
`, [id, params.shareId, params.sessionId, params.messageHash, now]);
|
||||||
|
|
||||||
|
this.saveToFile();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
share_id: params.shareId,
|
||||||
|
session_id: params.sessionId,
|
||||||
|
message_hash: params.messageHash,
|
||||||
|
signature: null,
|
||||||
|
status: 'pending',
|
||||||
|
error_message: null,
|
||||||
|
created_at: now,
|
||||||
|
completed_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新签名历史状态
|
||||||
|
*/
|
||||||
|
updateSigningHistory(id: string, params: {
|
||||||
|
status: SigningHistoryRecord['status'];
|
||||||
|
signature?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
|
||||||
|
const completedAt = params.status === 'completed' || params.status === 'failed'
|
||||||
|
? new Date().toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.db.run(`
|
||||||
|
UPDATE signing_history
|
||||||
|
SET status = ?, signature = ?, error_message = ?, completed_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [
|
||||||
|
params.status,
|
||||||
|
params.signature || null,
|
||||||
|
params.errorMessage || null,
|
||||||
|
completedAt,
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 share 的签名历史
|
||||||
|
*/
|
||||||
|
getSigningHistoryByShare(shareId: string): SigningHistoryRecord[] {
|
||||||
|
return this.queryToObjects<SigningHistoryRecord>(`
|
||||||
|
SELECT * FROM signing_history
|
||||||
|
WHERE share_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [shareId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 设置操作
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设置
|
||||||
|
*/
|
||||||
|
getSetting(key: string): string | undefined {
|
||||||
|
const row = this.queryOne<{ value: string }>(`SELECT value FROM settings WHERE key = ?`, [key]);
|
||||||
|
return row?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存设置
|
||||||
|
*/
|
||||||
|
setSetting(key: string, value: string): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
this.db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, [key, value]);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有设置
|
||||||
|
*/
|
||||||
|
getAllSettings(): Record<string, string> {
|
||||||
|
const rows = this.queryToObjects<SettingsRecord>(`SELECT key, value FROM settings`);
|
||||||
|
const settings: Record<string, string> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
settings[row.key] = row.value;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 导入导出
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出 share (加密备份)
|
||||||
|
*/
|
||||||
|
exportShare(id: string, password: string): Buffer {
|
||||||
|
const share = this.getShare(id, password);
|
||||||
|
const addresses = this.getAddressesByShare(id);
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
version: '1.0.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
share: {
|
||||||
|
session_id: share.session_id,
|
||||||
|
wallet_name: share.wallet_name,
|
||||||
|
party_id: share.party_id,
|
||||||
|
party_index: share.party_index,
|
||||||
|
threshold_t: share.threshold_t,
|
||||||
|
threshold_n: share.threshold_n,
|
||||||
|
public_key_hex: share.public_key_hex,
|
||||||
|
raw_share: share.raw_share,
|
||||||
|
participants: JSON.parse(share.participants_json),
|
||||||
|
},
|
||||||
|
addresses: addresses.map(addr => ({
|
||||||
|
chain: addr.chain,
|
||||||
|
derivation_path: addr.derivation_path,
|
||||||
|
address: addr.address,
|
||||||
|
public_key_hex: addr.public_key_hex,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const encrypted = this.encrypt(JSON.stringify(exportData), password);
|
||||||
|
return Buffer.from(encrypted, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入 share
|
||||||
|
*/
|
||||||
|
importShare(data: Buffer, password: string): ShareRecord {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const encrypted = data.toString('utf8');
|
||||||
|
const decrypted = this.decrypt(encrypted, password);
|
||||||
|
const exportData = JSON.parse(decrypted);
|
||||||
|
|
||||||
|
if (!exportData.version || !exportData.share) {
|
||||||
|
throw new Error('Invalid export file format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = this.queryOne<{ id: string }>(`
|
||||||
|
SELECT id FROM shares WHERE session_id = ? AND party_id = ?
|
||||||
|
`, [exportData.share.session_id, exportData.share.party_id]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Share already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 share
|
||||||
|
const share = this.saveShare({
|
||||||
|
sessionId: exportData.share.session_id,
|
||||||
|
walletName: exportData.share.wallet_name,
|
||||||
|
partyId: exportData.share.party_id,
|
||||||
|
partyIndex: exportData.share.party_index,
|
||||||
|
thresholdT: exportData.share.threshold_t,
|
||||||
|
thresholdN: exportData.share.threshold_n,
|
||||||
|
publicKeyHex: exportData.share.public_key_hex,
|
||||||
|
rawShare: exportData.share.raw_share,
|
||||||
|
participants: exportData.share.participants,
|
||||||
|
}, password);
|
||||||
|
|
||||||
|
// 恢复派生地址
|
||||||
|
if (exportData.addresses) {
|
||||||
|
for (const addr of exportData.addresses) {
|
||||||
|
this.saveDerivedAddress({
|
||||||
|
shareId: share.id,
|
||||||
|
chain: addr.chain,
|
||||||
|
derivationPath: addr.derivation_path,
|
||||||
|
address: addr.address,
|
||||||
|
publicKeyHex: addr.public_key_hex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭数据库连接
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
if (this.db) {
|
||||||
|
this.saveToFile();
|
||||||
|
this.db.close();
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,561 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
@ -0,0 +1,506 @@
|
||||||
|
/**
|
||||||
|
* 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 无需断开连接
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出默认服务实例
|
||||||
|
export const kavaTxService = new KavaTxService();
|
||||||
|
|
@ -5,7 +5,9 @@ const eventSubscriptions: Map<string, (event: unknown, ...args: unknown[]) => vo
|
||||||
|
|
||||||
// 暴露给渲染进程的 API
|
// 暴露给渲染进程的 API
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// ===========================================================================
|
||||||
// gRPC 相关 - Keygen
|
// gRPC 相关 - Keygen
|
||||||
|
// ===========================================================================
|
||||||
grpc: {
|
grpc: {
|
||||||
createSession: (params: {
|
createSession: (params: {
|
||||||
walletName: string;
|
walletName: string;
|
||||||
|
|
@ -65,8 +67,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 存储相关
|
// ===========================================================================
|
||||||
|
// 存储相关 (SQLite)
|
||||||
|
// ===========================================================================
|
||||||
storage: {
|
storage: {
|
||||||
|
// Share 操作
|
||||||
|
saveShare: (share: {
|
||||||
|
sessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
publicKey: string;
|
||||||
|
rawShare: string;
|
||||||
|
participants?: Array<{ partyId: string; name: string }>;
|
||||||
|
}, password: string) =>
|
||||||
|
ipcRenderer.invoke('storage:saveShare', { share, password }),
|
||||||
|
|
||||||
listShares: () => ipcRenderer.invoke('storage:listShares'),
|
listShares: () => ipcRenderer.invoke('storage:listShares'),
|
||||||
|
|
||||||
getShare: (id: string, password: string) =>
|
getShare: (id: string, password: string) =>
|
||||||
|
|
@ -81,13 +98,95 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
deleteShare: (id: string) =>
|
deleteShare: (id: string) =>
|
||||||
ipcRenderer.invoke('storage:deleteShare', { id }),
|
ipcRenderer.invoke('storage:deleteShare', { id }),
|
||||||
|
|
||||||
|
// 设置操作
|
||||||
getSettings: () => ipcRenderer.invoke('storage:getSettings'),
|
getSettings: () => ipcRenderer.invoke('storage:getSettings'),
|
||||||
|
|
||||||
saveSettings: (settings: unknown) =>
|
saveSettings: (settings: {
|
||||||
ipcRenderer.invoke('storage:saveSettings', { settings }),
|
messageRouterUrl?: string;
|
||||||
|
autoBackup?: boolean;
|
||||||
|
backupPath?: string;
|
||||||
|
}) => ipcRenderer.invoke('storage:saveSettings', { settings }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 地址派生相关
|
||||||
|
// ===========================================================================
|
||||||
|
address: {
|
||||||
|
// 派生指定链的地址
|
||||||
|
derive: (shareId: string, chain: string, password: string) =>
|
||||||
|
ipcRenderer.invoke('address:derive', { shareId, chain, password }),
|
||||||
|
|
||||||
|
// 派生所有支持链的地址
|
||||||
|
deriveAll: (shareId: string, password: string) =>
|
||||||
|
ipcRenderer.invoke('address:deriveAll', { shareId, password }),
|
||||||
|
|
||||||
|
// 获取 share 的所有派生地址
|
||||||
|
list: (shareId: string) =>
|
||||||
|
ipcRenderer.invoke('address:list', { shareId }),
|
||||||
|
|
||||||
|
// 获取支持的链列表
|
||||||
|
getSupportedChains: () =>
|
||||||
|
ipcRenderer.invoke('address:getSupportedChains'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 签名历史相关
|
||||||
|
// ===========================================================================
|
||||||
|
signing: {
|
||||||
|
// 获取签名历史
|
||||||
|
getHistory: (shareId: string) =>
|
||||||
|
ipcRenderer.invoke('signing:getHistory', { shareId }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Kava 区块链相关
|
||||||
|
// ===========================================================================
|
||||||
|
kava: {
|
||||||
|
// 查询 KAVA 余额
|
||||||
|
getBalance: (address: string) =>
|
||||||
|
ipcRenderer.invoke('kava:getBalance', { address }),
|
||||||
|
|
||||||
|
// 查询所有余额
|
||||||
|
getAllBalances: (address: string) =>
|
||||||
|
ipcRenderer.invoke('kava:getAllBalances', { address }),
|
||||||
|
|
||||||
|
// 查询账户信息
|
||||||
|
getAccountInfo: (address: string) =>
|
||||||
|
ipcRenderer.invoke('kava:getAccountInfo', { address }),
|
||||||
|
|
||||||
|
// 构建转账交易 (待 TSS 签名)
|
||||||
|
buildSendTx: (params: {
|
||||||
|
fromAddress: string;
|
||||||
|
toAddress: string;
|
||||||
|
amount: string;
|
||||||
|
publicKeyHex: string;
|
||||||
|
memo?: string;
|
||||||
|
}) => ipcRenderer.invoke('kava:buildSendTx', params),
|
||||||
|
|
||||||
|
// 完成签名并广播交易
|
||||||
|
completeTxAndBroadcast: (params: {
|
||||||
|
unsignedTx: unknown;
|
||||||
|
signatureHex: string;
|
||||||
|
}) => ipcRenderer.invoke('kava:completeTxAndBroadcast', params),
|
||||||
|
|
||||||
|
// 查询交易状态
|
||||||
|
getTxStatus: (txHash: string) =>
|
||||||
|
ipcRenderer.invoke('kava:getTxStatus', { txHash }),
|
||||||
|
|
||||||
|
// 获取配置
|
||||||
|
getConfig: () => ipcRenderer.invoke('kava:getConfig'),
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
updateConfig: (config: {
|
||||||
|
lcdEndpoint?: string;
|
||||||
|
rpcEndpoint?: string;
|
||||||
|
chainId?: string;
|
||||||
|
}) => ipcRenderer.invoke('kava:updateConfig', { config }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
// 对话框相关
|
// 对话框相关
|
||||||
|
// ===========================================================================
|
||||||
dialog: {
|
dialog: {
|
||||||
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
|
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// sql.js 类型声明
|
||||||
|
|
||||||
|
declare module 'sql.js' {
|
||||||
|
export interface SqlValue {
|
||||||
|
[key: string]: number | string | Uint8Array | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryExecResult {
|
||||||
|
columns: string[];
|
||||||
|
values: (number | string | Uint8Array | null)[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamsObject {
|
||||||
|
[key: string]: number | string | Uint8Array | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamsCallback {
|
||||||
|
(params: ParamsObject): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
run(sql: string, params?: unknown[]): Database;
|
||||||
|
exec(sql: string, params?: unknown[]): QueryExecResult[];
|
||||||
|
each(sql: string, params: unknown[], callback: (row: SqlValue) => void, done?: () => void): void;
|
||||||
|
prepare(sql: string): Statement;
|
||||||
|
export(): Uint8Array;
|
||||||
|
close(): void;
|
||||||
|
getRowsModified(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Statement {
|
||||||
|
bind(params?: unknown[]): boolean;
|
||||||
|
step(): boolean;
|
||||||
|
getAsObject(params?: ParamsObject): SqlValue;
|
||||||
|
get(params?: unknown[]): (number | string | Uint8Array | null)[];
|
||||||
|
getColumnNames(): string[];
|
||||||
|
run(params?: unknown[]): void;
|
||||||
|
reset(): void;
|
||||||
|
free(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlJsStatic {
|
||||||
|
Database: new (data?: ArrayLike<number> | Buffer | null) => Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function initSqlJs(config?: {
|
||||||
|
locateFile?: (path: string) => string;
|
||||||
|
}): Promise<SqlJsStatic>;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,16 +17,24 @@
|
||||||
"proto:generate": "grpc_tools_node_protoc --js_out=import_style=commonjs,binary:./proto --grpc_out=grpc_js:./proto --proto_path=../../api/proto ../../api/proto/*.proto"
|
"proto:generate": "grpc_tools_node_protoc --js_out=import_style=commonjs,binary:./proto --grpc_out=grpc_js:./proto --proto_path=../../api/proto ../../api/proto/*.proto"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cosmjs/amino": "^0.37.0",
|
||||||
|
"@cosmjs/encoding": "^0.37.0",
|
||||||
|
"@cosmjs/proto-signing": "^0.37.0",
|
||||||
|
"@cosmjs/stargate": "^0.37.0",
|
||||||
"@grpc/grpc-js": "^1.9.0",
|
"@grpc/grpc-js": "^1.9.0",
|
||||||
"@grpc/proto-loader": "^0.7.10",
|
"@grpc/proto-loader": "^0.7.10",
|
||||||
|
"@noble/curves": "^2.0.1",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"bech32": "^2.0.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"zustand": "^4.4.7",
|
"sql.js": "^1.13.0",
|
||||||
"qrcode.react": "^3.1.0",
|
"uuid": "^9.0.1",
|
||||||
"uuid": "^9.0.1"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
|
@ -38,6 +46,7 @@
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"electron": "^28.0.0",
|
"electron": "^28.0.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^24.9.1",
|
||||||
|
"electron-rebuild": "^3.2.9",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.54.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"grpc-tools": "^1.12.4",
|
"grpc-tools": "^1.12.4",
|
||||||
|
|
@ -61,7 +70,9 @@
|
||||||
{
|
{
|
||||||
"from": "proto",
|
"from": "proto",
|
||||||
"to": "proto",
|
"to": "proto",
|
||||||
"filter": ["**/*"]
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
// Electron API 类型定义
|
// Electron API 类型定义
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Share 相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
interface ShareEntry {
|
interface ShareEntry {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -8,13 +12,58 @@ interface ShareEntry {
|
||||||
partyIndex: number;
|
partyIndex: number;
|
||||||
threshold: { t: number; n: number };
|
threshold: { t: number; n: number };
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
encryptedShare: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
metadata: {
|
lastUsedAt?: string;
|
||||||
participants: Array<{ partyId: string; name: string }>;
|
participants: Array<{ partyId: string; name: string }>;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ShareWithRawData extends ShareEntry {
|
||||||
|
rawShare: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 派生地址相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
interface DerivedAddress {
|
||||||
|
id: string;
|
||||||
|
shareId: string;
|
||||||
|
chain: string;
|
||||||
|
chainName: string;
|
||||||
|
derivationPath: string;
|
||||||
|
address: string;
|
||||||
|
publicKeyHex: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChainConfig {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
coinType: number;
|
||||||
|
curve: 'secp256k1' | 'ed25519';
|
||||||
|
derivationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 签名历史相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
interface SigningHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
shareId: string;
|
||||||
|
sessionId: string;
|
||||||
|
messageHash: string;
|
||||||
|
signature: string | null;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 会话相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
interface SessionInfo {
|
interface SessionInfo {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
walletName: string;
|
walletName: string;
|
||||||
|
|
@ -43,12 +92,20 @@ interface SessionState {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 设置相关
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
messageRouterUrl: string;
|
messageRouterUrl: string;
|
||||||
autoBackup: boolean;
|
autoBackup: boolean;
|
||||||
backupPath: string;
|
backupPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// API 参数和结果类型
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
interface CreateSessionParams {
|
interface CreateSessionParams {
|
||||||
walletName: string;
|
walletName: string;
|
||||||
thresholdT: number;
|
thresholdT: number;
|
||||||
|
|
@ -135,6 +192,12 @@ interface ListSharesResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SaveShareResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: ShareEntry;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportShareResult {
|
interface ExportShareResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: ArrayBuffer;
|
data?: ArrayBuffer;
|
||||||
|
|
@ -142,7 +205,134 @@ interface ExportShareResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeriveAddressResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: DerivedAddress;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeriveAllAddressesResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: DerivedAddress[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListAddressesResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: DerivedAddress[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetSigningHistoryResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: SigningHistoryEntry[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Kava 相关类型
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
interface KavaBalance {
|
||||||
|
denom: string;
|
||||||
|
amount: string;
|
||||||
|
formatted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KavaAccountInfo {
|
||||||
|
address: string;
|
||||||
|
accountNumber: number;
|
||||||
|
sequence: number;
|
||||||
|
balances: KavaBalance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KavaUnsignedTx {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KavaTxBroadcastResult {
|
||||||
|
success: boolean;
|
||||||
|
txHash?: string;
|
||||||
|
code?: number;
|
||||||
|
rawLog?: string;
|
||||||
|
gasUsed?: string;
|
||||||
|
gasWanted?: string;
|
||||||
|
height?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KavaTxStatus {
|
||||||
|
found: boolean;
|
||||||
|
status: 'pending' | 'success' | 'failed';
|
||||||
|
height?: number;
|
||||||
|
code?: number;
|
||||||
|
rawLog?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KavaConfig {
|
||||||
|
lcdEndpoint: string;
|
||||||
|
rpcEndpoint: string;
|
||||||
|
chainId: string;
|
||||||
|
prefix: string;
|
||||||
|
denom: string;
|
||||||
|
gasPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetKavaBalanceResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaBalance;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetAllKavaBalancesResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaBalance[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetKavaAccountInfoResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaAccountInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildKavaSendTxResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaUnsignedTx;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompleteTxAndBroadcastResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaTxBroadcastResult;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetKavaTxStatusResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: KavaTxStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Electron API 接口
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
|
// gRPC 相关
|
||||||
grpc: {
|
grpc: {
|
||||||
createSession: (params: CreateSessionParams) => Promise<CreateSessionResult>;
|
createSession: (params: CreateSessionParams) => Promise<CreateSessionResult>;
|
||||||
joinSession: (sessionId: string, participantName: string) => Promise<JoinSessionResult>;
|
joinSession: (sessionId: string, participantName: string) => Promise<JoinSessionResult>;
|
||||||
|
|
@ -155,15 +345,63 @@ interface ElectronAPI {
|
||||||
joinSigningSession: (params: JoinSigningSessionParams) => Promise<JoinSigningSessionResult>;
|
joinSigningSession: (params: JoinSigningSessionParams) => Promise<JoinSigningSessionResult>;
|
||||||
subscribeSigningProgress: (sessionId: string, callback: (event: SigningProgressEvent) => void) => () => void;
|
subscribeSigningProgress: (sessionId: string, callback: (event: SigningProgressEvent) => void) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 存储相关 (SQLite)
|
||||||
storage: {
|
storage: {
|
||||||
|
saveShare: (share: {
|
||||||
|
sessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
publicKey: string;
|
||||||
|
rawShare: string;
|
||||||
|
participants?: Array<{ partyId: string; name: string }>;
|
||||||
|
}, password: string) => Promise<SaveShareResult>;
|
||||||
listShares: () => Promise<ListSharesResult>;
|
listShares: () => Promise<ListSharesResult>;
|
||||||
getShare: (id: string, password: string) => Promise<ShareEntry | null>;
|
getShare: (id: string, password: string) => Promise<ShareWithRawData | null>;
|
||||||
exportShare: (id: string, password: string) => Promise<ExportShareResult>;
|
exportShare: (id: string, password: string) => Promise<ExportShareResult>;
|
||||||
importShare: (filePath: string, password: string) => Promise<{ success: boolean; share?: ShareEntry; error?: string }>;
|
importShare: (filePath: string, password: string) => Promise<{ success: boolean; share?: ShareEntry; error?: string }>;
|
||||||
deleteShare: (id: string) => Promise<{ success: boolean; error?: string }>;
|
deleteShare: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getSettings: () => Promise<Settings | null>;
|
getSettings: () => Promise<Settings | null>;
|
||||||
saveSettings: (settings: Settings) => Promise<void>;
|
saveSettings: (settings: Partial<Settings>) => Promise<{ success: boolean; error?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 地址派生相关
|
||||||
|
address: {
|
||||||
|
derive: (shareId: string, chain: string, password: string) => Promise<DeriveAddressResult>;
|
||||||
|
deriveAll: (shareId: string, password: string) => Promise<DeriveAllAddressesResult>;
|
||||||
|
list: (shareId: string) => Promise<ListAddressesResult>;
|
||||||
|
getSupportedChains: () => Promise<ChainConfig[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 签名历史相关
|
||||||
|
signing: {
|
||||||
|
getHistory: (shareId: string) => Promise<GetSigningHistoryResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kava 区块链相关
|
||||||
|
kava: {
|
||||||
|
getBalance: (address: string) => Promise<GetKavaBalanceResult>;
|
||||||
|
getAllBalances: (address: string) => Promise<GetAllKavaBalancesResult>;
|
||||||
|
getAccountInfo: (address: string) => Promise<GetKavaAccountInfoResult>;
|
||||||
|
buildSendTx: (params: {
|
||||||
|
fromAddress: string;
|
||||||
|
toAddress: string;
|
||||||
|
amount: string;
|
||||||
|
publicKeyHex: string;
|
||||||
|
memo?: string;
|
||||||
|
}) => Promise<BuildKavaSendTxResult>;
|
||||||
|
completeTxAndBroadcast: (params: {
|
||||||
|
unsignedTx: KavaUnsignedTx;
|
||||||
|
signatureHex: string;
|
||||||
|
}) => Promise<CompleteTxAndBroadcastResult>;
|
||||||
|
getTxStatus: (txHash: string) => Promise<GetKavaTxStatusResult>;
|
||||||
|
getConfig: () => Promise<KavaConfig>;
|
||||||
|
updateConfig: (config: Partial<KavaConfig>) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 对话框相关
|
||||||
dialog: {
|
dialog: {
|
||||||
selectDirectory: () => Promise<string | null>;
|
selectDirectory: () => Promise<string | null>;
|
||||||
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
|
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* Kava EVM 地址派生工具
|
||||||
|
* 从 ECDSA 公钥派生 EVM 兼容地址
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将十六进制字符串转换为 Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||||
|
const bytes = new Uint8Array(cleanHex.length / 2);
|
||||||
|
for (let i = 0; i < cleanHex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Uint8Array 转换为十六进制字符串
|
||||||
|
*/
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keccak-256 哈希实现 (简化版)
|
||||||
|
* 用于从公钥派生 EVM 地址
|
||||||
|
*/
|
||||||
|
async function keccak256(data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
// 使用 SubtleCrypto 的 SHA-256 作为备选
|
||||||
|
// 注意: 真实场景需要使用 keccak-256,这里使用简化实现
|
||||||
|
// 在生产环境中应该使用 js-sha3 或 ethers.js
|
||||||
|
|
||||||
|
// 简单的 Keccak-256 实现常量
|
||||||
|
const RC = [
|
||||||
|
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an,
|
||||||
|
0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
|
||||||
|
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an,
|
||||||
|
0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
||||||
|
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n,
|
||||||
|
0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
|
||||||
|
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n,
|
||||||
|
0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROTC = [
|
||||||
|
[0, 36, 3, 41, 18],
|
||||||
|
[1, 44, 10, 45, 2],
|
||||||
|
[62, 6, 43, 15, 61],
|
||||||
|
[28, 55, 25, 21, 56],
|
||||||
|
[27, 20, 39, 8, 14],
|
||||||
|
];
|
||||||
|
|
||||||
|
function rotl64(x: bigint, n: number): bigint {
|
||||||
|
return ((x << BigInt(n)) | (x >> BigInt(64 - n))) & 0xffffffffffffffffn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keccakF(state: bigint[][]): void {
|
||||||
|
for (let round = 0; round < 24; round++) {
|
||||||
|
// Theta
|
||||||
|
const C: bigint[] = [];
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
C[x] = state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4];
|
||||||
|
}
|
||||||
|
const D: bigint[] = [];
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1);
|
||||||
|
}
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
state[x][y] ^= D[x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rho and Pi
|
||||||
|
const B: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
B[y][(2 * x + 3 * y) % 5] = rotl64(state[x][y], ROTC[x][y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chi
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
state[x][y] = B[x][y] ^ (~B[(x + 1) % 5][y] & B[(x + 2) % 5][y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iota
|
||||||
|
state[0][0] ^= RC[round];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keccak-256: rate = 1088 bits = 136 bytes, capacity = 512 bits
|
||||||
|
const rate = 136;
|
||||||
|
const outputLen = 32;
|
||||||
|
|
||||||
|
// Initialize state
|
||||||
|
const state: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
|
||||||
|
|
||||||
|
// Pad message
|
||||||
|
const padded = new Uint8Array(Math.ceil((data.length + 1) / rate) * rate);
|
||||||
|
padded.set(data);
|
||||||
|
padded[data.length] = 0x01;
|
||||||
|
padded[padded.length - 1] |= 0x80;
|
||||||
|
|
||||||
|
// Absorb
|
||||||
|
for (let i = 0; i < padded.length; i += rate) {
|
||||||
|
for (let j = 0; j < rate && i + j < padded.length; j += 8) {
|
||||||
|
const x = Math.floor(j / 8) % 5;
|
||||||
|
const y = Math.floor(Math.floor(j / 8) / 5);
|
||||||
|
let lane = 0n;
|
||||||
|
for (let k = 0; k < 8 && i + j + k < padded.length; k++) {
|
||||||
|
lane |= BigInt(padded[i + j + k]) << BigInt(k * 8);
|
||||||
|
}
|
||||||
|
state[x][y] ^= lane;
|
||||||
|
}
|
||||||
|
keccakF(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squeeze
|
||||||
|
const output = new Uint8Array(outputLen);
|
||||||
|
for (let i = 0; i < outputLen; i += 8) {
|
||||||
|
const x = Math.floor(i / 8) % 5;
|
||||||
|
const y = Math.floor(Math.floor(i / 8) / 5);
|
||||||
|
const lane = state[x][y];
|
||||||
|
for (let k = 0; k < 8 && i + k < outputLen; k++) {
|
||||||
|
output[i + k] = Number((lane >> BigInt(k * 8)) & 0xffn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ECDSA 压缩/未压缩公钥派生 EVM 地址
|
||||||
|
*
|
||||||
|
* @param publicKey - 公钥的十六进制字符串 (压缩或未压缩格式)
|
||||||
|
* @returns EVM 地址 (带 0x 前缀)
|
||||||
|
*/
|
||||||
|
export async function deriveEvmAddress(publicKey: string): Promise<string> {
|
||||||
|
const pubKeyBytes = hexToBytes(publicKey);
|
||||||
|
|
||||||
|
// EVM 地址派生使用未压缩公钥的 x,y 坐标 (去掉 04 前缀)
|
||||||
|
// 如果是压缩公钥 (33 bytes, 02/03 前缀), 需要先解压
|
||||||
|
let uncompressedPubKey: Uint8Array;
|
||||||
|
|
||||||
|
if (pubKeyBytes.length === 65 && pubKeyBytes[0] === 0x04) {
|
||||||
|
// 已经是未压缩格式,去掉 04 前缀
|
||||||
|
uncompressedPubKey = pubKeyBytes.slice(1);
|
||||||
|
} else if (pubKeyBytes.length === 64) {
|
||||||
|
// 无前缀的未压缩格式
|
||||||
|
uncompressedPubKey = pubKeyBytes;
|
||||||
|
} else if (pubKeyBytes.length === 33 && (pubKeyBytes[0] === 0x02 || pubKeyBytes[0] === 0x03)) {
|
||||||
|
// 压缩格式,需要解压 - 这里简化处理,实际需要椭圆曲线计算
|
||||||
|
// 在真实场景中应该使用 elliptic 或 secp256k1 库
|
||||||
|
throw new Error('压缩公钥暂不支持,请提供未压缩公钥');
|
||||||
|
} else {
|
||||||
|
throw new Error(`无效的公钥格式: length=${pubKeyBytes.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对 64 字节的公钥数据进行 Keccak-256 哈希
|
||||||
|
const hash = await keccak256(uncompressedPubKey);
|
||||||
|
|
||||||
|
// 取最后 20 字节作为地址
|
||||||
|
const addressBytes = hash.slice(-20);
|
||||||
|
|
||||||
|
return '0x' + bytesToHex(addressBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 EVM 地址格式
|
||||||
|
*
|
||||||
|
* @param address - 要验证的地址
|
||||||
|
* @returns 是否是有效的 EVM 地址
|
||||||
|
*/
|
||||||
|
export function isValidEvmAddress(address: string): boolean {
|
||||||
|
if (!address) return false;
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化地址显示 (缩短)
|
||||||
|
*
|
||||||
|
* @param address - 完整地址
|
||||||
|
* @param prefixLen - 前缀长度 (默认 6)
|
||||||
|
* @param suffixLen - 后缀长度 (默认 4)
|
||||||
|
* @returns 缩短的地址
|
||||||
|
*/
|
||||||
|
export function formatAddress(address: string, prefixLen = 6, suffixLen = 4): string {
|
||||||
|
if (!address || address.length < prefixLen + suffixLen + 2) return address;
|
||||||
|
return `${address.slice(0, prefixLen + 2)}...${address.slice(-suffixLen)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Kava 区块浏览器 URL
|
||||||
|
*
|
||||||
|
* @param address - EVM 地址
|
||||||
|
* @param isTestnet - 是否是测试网
|
||||||
|
* @returns 区块浏览器 URL
|
||||||
|
*/
|
||||||
|
export function getKavaExplorerUrl(address: string, isTestnet = true): string {
|
||||||
|
const baseUrl = isTestnet
|
||||||
|
? 'https://testnet.kavascan.com'
|
||||||
|
: 'https://kavascan.com';
|
||||||
|
return `${baseUrl}/address/${address}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue