feat(co-managed-wallet): 添加分布式多方共管钱包创建功能
## 功能概述
实现分布式多方共管钱包创建功能,包括 Admin-Web 扩展和 Service-Party 桌面应用。
## 主要变更
### 1. Admin-Web 扩展 (前端)
- 新增 CoManagedWalletSection 组件 (frontend/admin-web/src/components/features/co-managed-wallet/)
- 在授权管理页面添加共管钱包入口卡片
- 实现创建钱包向导: 配置 → 邀请 → 生成 → 完成
- 包含组件: ThresholdConfig, InviteQRCode, ParticipantList, SessionProgress, WalletResult
### 2. Admin-Service 后端 API
- 新增共管钱包领域实体和枚举 (domain/entities/co-managed-wallet.entity.ts)
- 新增 REST 控制器 (api/controllers/co-managed-wallet.controller.ts)
- 新增服务层 (application/services/co-managed-wallet.service.ts)
- 新增 Prisma 模型: CoManagedWalletSession, CoManagedWallet
- 更新 app.module.ts 注册新模块
### 3. Session Coordinator 扩展 (Go)
- 新增会话类型: SessionTypeCoManagedKeygen ("co_managed_keygen")
- 扩展 MPCSession 实体添加 WalletName 和 InviteCode 字段
- 更新 PostgreSQL 和 Redis 适配器支持新字段
- 新增数据库迁移: 008_add_co_managed_wallet_fields
### 4. Service-Party 桌面应用 (新项目)
- 位置: backend/mpc-system/services/service-party-app/
- 技术栈: Electron + React + TypeScript + Vite
- 包含模块:
- gRPC 客户端 (连接 Message Router)
- TSS 处理器 (子进程方式运行 Go TSS 协议)
- 本地加密存储 (AES-256-GCM)
- 页面: Home, Join, Create, Session, Settings
## 修改的现有文件 (便于回滚)
1. backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go
- 添加 SessionTypeCoManagedKeygen 常量
- 添加 IsKeygen() 方法
- 添加 WalletName, InviteCode 字段
- 更新 ReconstructSession, ToDTO, SessionDTO
2. backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go
- 更新 SQL 查询包含 wallet_name, invite_code
- 更新 Save, FindByUUID, FindByStatus 等方法
- 更新 scanSessions, sessionRow
3. backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go
- 更新 sessionCacheEntry 结构
- 更新 sessionToCacheEntry, cacheEntryToSession
4. backend/services/admin-service/prisma/schema.prisma
- 新增 WalletSessionStatus 枚举
- 新增 CoManagedWalletSession, CoManagedWallet 模型
5. backend/services/admin-service/src/app.module.ts
- 导入并注册共管钱包相关组件
6. frontend/admin-web/src/app/(dashboard)/authorization/page.tsx
- 导入并添加 CoManagedWalletSection
7. frontend/admin-web/src/infrastructure/api/endpoints.ts
- 添加 CO_MANAGED_WALLETS API 端点
## 回滚说明
如需回滚此功能:
1. 回滚数据库迁移: 运行 008_add_co_managed_wallet_fields.down.sql
2. 删除新增文件夹:
- backend/mpc-system/services/service-party-app/
- frontend/admin-web/src/components/features/co-managed-wallet/
- backend/services/admin-service/src/**/co-managed-wallet*
3. 恢复修改的文件到前一个版本
4. 运行 prisma generate 重新生成 Prisma 客户端
🤖 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
9c7dc6f511
commit
fea01642e7
|
|
@ -440,7 +440,9 @@
|
|||
"Bash(npx jest --testPathPattern=\"referral\" --passWithNoTests)",
|
||||
"Bash(npx jest --testPathPattern=\"wallet\" --passWithNoTests)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nrefactor\\(mobile-app\\): 修改\"我的\"页面文案\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''\nfeat\\(identity-service\\): 增强钱包生成可靠性,确保100%生成成功\n\n核心改进:\n- 基于数据库扫描代替Redis扫描,防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容:\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\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\\(identity-service\\): 增强钱包生成可靠性,确保100%生成成功\n\n核心改进:\n- 基于数据库扫描代替Redis扫描,防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容:\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\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(xargs ls:*)",
|
||||
"Bash(tree:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- Migration: 008_add_co_managed_wallet_fields (rollback)
|
||||
-- Description: Remove wallet_name and invite_code fields and revert session_type constraint
|
||||
|
||||
-- Drop the index for invite_code
|
||||
DROP INDEX IF EXISTS idx_mpc_sessions_invite_code;
|
||||
|
||||
-- Remove the columns
|
||||
ALTER TABLE mpc_sessions
|
||||
DROP COLUMN IF EXISTS wallet_name,
|
||||
DROP COLUMN IF EXISTS invite_code;
|
||||
|
||||
-- Drop the updated session_type constraint
|
||||
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
|
||||
|
||||
-- Restore the original session_type constraint
|
||||
ALTER TABLE mpc_sessions
|
||||
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign'));
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- Migration: 008_add_co_managed_wallet_fields
|
||||
-- Description: Add wallet_name and invite_code fields for co-managed wallet sessions
|
||||
-- and extend session_type to support 'co_managed_keygen'
|
||||
|
||||
-- Add new columns for co-managed wallet sessions
|
||||
ALTER TABLE mpc_sessions
|
||||
ADD COLUMN wallet_name VARCHAR(255),
|
||||
ADD COLUMN invite_code VARCHAR(50);
|
||||
|
||||
-- Create index for invite_code lookups
|
||||
CREATE INDEX idx_mpc_sessions_invite_code ON mpc_sessions(invite_code) WHERE invite_code IS NOT NULL;
|
||||
|
||||
-- Drop the existing session_type constraint
|
||||
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
|
||||
|
||||
-- Add updated session_type constraint including 'co_managed_keygen'
|
||||
ALTER TABLE mpc_sessions
|
||||
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign', 'co_managed_keygen'));
|
||||
|
||||
-- Add comment for the new columns
|
||||
COMMENT ON COLUMN mpc_sessions.wallet_name IS 'Wallet name for co-managed wallet sessions';
|
||||
COMMENT ON COLUMN mpc_sessions.invite_code IS 'Invite code for co-managed wallet sessions - used for participants to join';
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.rwadurian.service-party",
|
||||
"productName": "Service Party",
|
||||
"copyright": "Copyright © 2024 RWADurian",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main.js"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico",
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Service Party"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.icns",
|
||||
"category": "public.app-category.utilities",
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"icon": "build/icons",
|
||||
"category": "Utility",
|
||||
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import * as path from 'path';
|
||||
import express from 'express';
|
||||
import { GrpcClient } from './modules/grpc-client';
|
||||
import { SecureStorage } from './modules/storage';
|
||||
|
||||
// 内置 HTTP 服务器端口
|
||||
const HTTP_PORT = 3456;
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let grpcClient: GrpcClient | null = null;
|
||||
let storage: SecureStorage | null = null;
|
||||
let httpServer: ReturnType<typeof express.application.listen> | null = null;
|
||||
|
||||
// 创建主窗口
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: 'hiddenInset',
|
||||
show: false,
|
||||
});
|
||||
|
||||
// 开发模式下加载 Vite 开发服务器
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// 生产模式下加载打包后的文件
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// 处理外部链接
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
// 启动内置 HTTP 服务器
|
||||
function startHttpServer() {
|
||||
const expressApp = express();
|
||||
|
||||
expressApp.use(express.json());
|
||||
expressApp.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// API 路由
|
||||
expressApp.get('/api/status', (_req, res) => {
|
||||
res.json({
|
||||
connected: grpcClient?.isConnected() ?? false,
|
||||
partyId: grpcClient?.getPartyId() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// 所有其他路由返回 index.html (SPA)
|
||||
expressApp.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
});
|
||||
|
||||
httpServer = expressApp.listen(HTTP_PORT, '127.0.0.1', () => {
|
||||
console.log(`HTTP server running at http://127.0.0.1:${HTTP_PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化服务
|
||||
async function initServices() {
|
||||
// 初始化 gRPC 客户端
|
||||
grpcClient = new GrpcClient();
|
||||
|
||||
// 初始化安全存储
|
||||
storage = new SecureStorage();
|
||||
|
||||
// 设置 IPC 处理器
|
||||
setupIpcHandlers();
|
||||
}
|
||||
|
||||
// 设置 IPC 通信处理器
|
||||
function setupIpcHandlers() {
|
||||
// gRPC 连接
|
||||
ipcMain.handle('grpc:connect', async (_event, { host, port }) => {
|
||||
try {
|
||||
await grpcClient?.connect(host, port);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 注册为参与方
|
||||
ipcMain.handle('grpc:register', async (_event, { partyId, role }) => {
|
||||
try {
|
||||
await grpcClient?.registerParty(partyId, role);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 加入会话
|
||||
ipcMain.handle('grpc:joinSession', async (_event, { sessionId, partyId, joinToken }) => {
|
||||
try {
|
||||
const result = await grpcClient?.joinSession(sessionId, partyId, joinToken);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// 存储 - 保存 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, { data, password }) => {
|
||||
try {
|
||||
const share = storage?.importShare(data, password);
|
||||
return { success: true, data: share };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 应用生命周期
|
||||
app.whenReady().then(async () => {
|
||||
await initServices();
|
||||
startHttpServer();
|
||||
createWindow();
|
||||
|
||||
// 自动打开浏览器 (可选)
|
||||
if (process.env.OPEN_BROWSER !== 'false') {
|
||||
shell.openExternal(`http://127.0.0.1:${HTTP_PORT}`);
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// 清理资源
|
||||
grpcClient?.disconnect();
|
||||
httpServer?.close();
|
||||
});
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import * as path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Proto 文件路径
|
||||
const PROTO_PATH = path.join(__dirname, '../../proto/message_router.proto');
|
||||
|
||||
// 加载 Proto 定义
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
sessionType: string;
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
messageHash?: Buffer;
|
||||
keygenSessionId?: string;
|
||||
}
|
||||
|
||||
interface PartyInfo {
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
}
|
||||
|
||||
interface JoinSessionResponse {
|
||||
success: boolean;
|
||||
sessionInfo?: SessionInfo;
|
||||
partyIndex: number;
|
||||
otherParties: PartyInfo[];
|
||||
}
|
||||
|
||||
interface MPCMessage {
|
||||
messageId: string;
|
||||
sessionId: string;
|
||||
fromParty: string;
|
||||
isBroadcast: boolean;
|
||||
roundNumber: number;
|
||||
payload: Buffer;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SessionEvent {
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
sessionId: string;
|
||||
thresholdN: number;
|
||||
thresholdT: number;
|
||||
selectedParties: string[];
|
||||
joinTokens: Record<string, string>;
|
||||
messageHash?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* gRPC 客户端 - 连接到 Message Router
|
||||
*/
|
||||
export class GrpcClient extends EventEmitter {
|
||||
private client: grpc.Client | null = null;
|
||||
private connected = false;
|
||||
private partyId: string | null = null;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private messageStream: grpc.ClientReadableStream<MPCMessage> | null = null;
|
||||
private eventStream: grpc.ClientReadableStream<SessionEvent> | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 Message Router
|
||||
*/
|
||||
async connect(host: string, port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proto = grpc.loadPackageDefinition(packageDefinition) as Record<string, unknown>;
|
||||
const MessageRouter = (proto.mpc?.router?.v1 as Record<string, unknown>)?.MessageRouter as grpc.ServiceClientConstructor;
|
||||
|
||||
if (!MessageRouter) {
|
||||
reject(new Error('Failed to load MessageRouter service definition'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new MessageRouter(
|
||||
`${host}:${port}`,
|
||||
grpc.credentials.createInsecure()
|
||||
) as grpc.Client;
|
||||
|
||||
// 等待连接就绪
|
||||
const deadline = new Date();
|
||||
deadline.setSeconds(deadline.getSeconds() + 10);
|
||||
|
||||
(this.client as grpc.Client & { waitForReady: (deadline: Date, callback: (err?: Error) => void) => void })
|
||||
.waitForReady(deadline, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
this.connected = true;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
|
||||
if (this.messageStream) {
|
||||
this.messageStream.cancel();
|
||||
this.messageStream = null;
|
||||
}
|
||||
|
||||
if (this.eventStream) {
|
||||
this.eventStream.cancel();
|
||||
this.eventStream = null;
|
||||
}
|
||||
|
||||
if (this.client) {
|
||||
(this.client as grpc.Client & { close: () => void }).close();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.partyId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已连接
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Party ID
|
||||
*/
|
||||
getPartyId(): string | null {
|
||||
return this.partyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册为参与方
|
||||
*/
|
||||
async registerParty(partyId: string, role: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { registerParty: (req: unknown, callback: (err: Error | null, res: { success: boolean }) => void) => void })
|
||||
.registerParty(
|
||||
{
|
||||
party_id: partyId,
|
||||
party_role: role,
|
||||
version: '1.0.0',
|
||||
},
|
||||
(err: Error | null, response: { success: boolean }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (!response.success) {
|
||||
reject(new Error('Registration failed'));
|
||||
} else {
|
||||
this.partyId = partyId;
|
||||
this.startHeartbeat();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.client && this.partyId) {
|
||||
(this.client as grpc.Client & { heartbeat: (req: unknown, callback: (err: Error | null) => void) => void })
|
||||
.heartbeat(
|
||||
{ party_id: this.partyId },
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
console.error('Heartbeat failed:', err.message);
|
||||
this.emit('connectionError', err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, 30000); // 每 30 秒一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话
|
||||
*/
|
||||
async joinSession(sessionId: string, partyId: string, joinToken: string): Promise<JoinSessionResponse> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { joinSession: (req: unknown, callback: (err: Error | null, res: JoinSessionResponse) => void) => void })
|
||||
.joinSession(
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
join_token: joinToken,
|
||||
},
|
||||
(err: Error | null, response: JoinSessionResponse) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅会话事件
|
||||
*/
|
||||
subscribeSessionEvents(partyId: string): void {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
this.eventStream = (this.client as grpc.Client & { subscribeSessionEvents: (req: unknown) => grpc.ClientReadableStream<SessionEvent> })
|
||||
.subscribeSessionEvents({ party_id: partyId });
|
||||
|
||||
this.eventStream.on('data', (event: SessionEvent) => {
|
||||
this.emit('sessionEvent', event);
|
||||
});
|
||||
|
||||
this.eventStream.on('error', (err: Error) => {
|
||||
console.error('Session event stream error:', err.message);
|
||||
this.emit('streamError', err);
|
||||
});
|
||||
|
||||
this.eventStream.on('end', () => {
|
||||
console.log('Session event stream ended');
|
||||
this.emit('streamEnd');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 MPC 消息
|
||||
*/
|
||||
subscribeMessages(sessionId: string, partyId: string): void {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
|
||||
.subscribeMessages({
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
});
|
||||
|
||||
this.messageStream.on('data', (message: MPCMessage) => {
|
||||
this.emit('mpcMessage', message);
|
||||
});
|
||||
|
||||
this.messageStream.on('error', (err: Error) => {
|
||||
console.error('Message stream error:', err.message);
|
||||
this.emit('messageStreamError', err);
|
||||
});
|
||||
|
||||
this.messageStream.on('end', () => {
|
||||
console.log('Message stream ended');
|
||||
this.emit('messageStreamEnd');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 MPC 消息
|
||||
*/
|
||||
async routeMessage(
|
||||
sessionId: string,
|
||||
fromParty: string,
|
||||
toParties: string[],
|
||||
roundNumber: number,
|
||||
payload: Buffer
|
||||
): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { routeMessage: (req: unknown, callback: (err: Error | null, res: { message_id: string }) => void) => void })
|
||||
.routeMessage(
|
||||
{
|
||||
session_id: sessionId,
|
||||
from_party: fromParty,
|
||||
to_parties: toParties,
|
||||
round_number: roundNumber,
|
||||
payload: payload,
|
||||
},
|
||||
(err: Error | null, response: { message_id: string }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response.message_id);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告完成
|
||||
*/
|
||||
async reportCompletion(sessionId: string, partyId: string, publicKey: Buffer): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.client as grpc.Client & { reportCompletion: (req: unknown, callback: (err: Error | null, res: { all_completed: boolean }) => void) => void })
|
||||
.reportCompletion(
|
||||
{
|
||||
session_id: sessionId,
|
||||
party_id: partyId,
|
||||
public_key: publicKey,
|
||||
},
|
||||
(err: Error | null, response: { all_completed: boolean }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(response.all_completed);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
import Store from 'electron-store';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Share 数据结构
|
||||
export interface ShareEntry {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
threshold: {
|
||||
t: number;
|
||||
n: number;
|
||||
};
|
||||
publicKey: string;
|
||||
encryptedShare: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
metadata: {
|
||||
participants: Array<{
|
||||
partyId: string;
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// 存储的数据结构
|
||||
interface StoreSchema {
|
||||
version: string;
|
||||
shares: ShareEntry[];
|
||||
settings: {
|
||||
messageRouterHost: string;
|
||||
messageRouterPort: number;
|
||||
autoBackup: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 加密配置
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32;
|
||||
const IV_LENGTH = 16;
|
||||
const SALT_LENGTH = 32;
|
||||
const TAG_LENGTH = 16;
|
||||
const ITERATIONS = 100000;
|
||||
|
||||
/**
|
||||
* 安全存储类 - 本地加密存储 share
|
||||
*/
|
||||
export class SecureStorage {
|
||||
private store: Store<StoreSchema>;
|
||||
|
||||
constructor() {
|
||||
this.store = new Store<StoreSchema>({
|
||||
name: 'service-party-data',
|
||||
defaults: {
|
||||
version: '1.0.0',
|
||||
shares: [],
|
||||
settings: {
|
||||
messageRouterHost: 'localhost',
|
||||
messageRouterPort: 9092,
|
||||
autoBackup: false,
|
||||
},
|
||||
},
|
||||
encryptionKey: 'service-party-app-encryption-key', // 基础加密,share 数据还有额外加密
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从密码派生密钥
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 share
|
||||
*/
|
||||
saveShare(share: Omit<ShareEntry, 'id' | 'createdAt' | 'encryptedShare'> & { rawShare: string }, password: string): ShareEntry {
|
||||
const encryptedShare = this.encrypt(share.rawShare, password);
|
||||
|
||||
const entry: ShareEntry = {
|
||||
id: uuidv4(),
|
||||
sessionId: share.sessionId,
|
||||
walletName: share.walletName,
|
||||
partyId: share.partyId,
|
||||
partyIndex: share.partyIndex,
|
||||
threshold: share.threshold,
|
||||
publicKey: share.publicKey,
|
||||
encryptedShare,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: share.metadata,
|
||||
};
|
||||
|
||||
const shares = this.store.get('shares', []);
|
||||
shares.push(entry);
|
||||
this.store.set('shares', shares);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 share 列表 (不含加密数据)
|
||||
*/
|
||||
listShares(): Omit<ShareEntry, 'encryptedShare'>[] {
|
||||
const shares = this.store.get('shares', []);
|
||||
return shares.map(({ encryptedShare: _, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 share (解密)
|
||||
*/
|
||||
getShare(id: string, password: string): ShareEntry & { rawShare: string } {
|
||||
const shares = this.store.get('shares', []);
|
||||
const share = shares.find((s) => s.id === id);
|
||||
|
||||
if (!share) {
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
const rawShare = this.decrypt(share.encryptedShare, password);
|
||||
|
||||
return {
|
||||
...share,
|
||||
rawShare,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 share 使用时间
|
||||
*/
|
||||
updateLastUsed(id: string): void {
|
||||
const shares = this.store.get('shares', []);
|
||||
const index = shares.findIndex((s) => s.id === id);
|
||||
|
||||
if (index !== -1) {
|
||||
shares[index].lastUsedAt = new Date().toISOString();
|
||||
this.store.set('shares', shares);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 share
|
||||
*/
|
||||
deleteShare(id: string): void {
|
||||
const shares = this.store.get('shares', []);
|
||||
const filtered = shares.filter((s) => s.id !== id);
|
||||
this.store.set('shares', filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 share (加密后的备份文件)
|
||||
*/
|
||||
exportShare(id: string, password: string): Buffer {
|
||||
const share = this.getShare(id, password);
|
||||
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
share: {
|
||||
...share,
|
||||
// 导出时使用新密码重新加密
|
||||
encryptedShare: this.encrypt(share.rawShare, password),
|
||||
},
|
||||
};
|
||||
|
||||
// 再次加密整个导出数据
|
||||
const encryptedExport = this.encrypt(JSON.stringify(exportData), password);
|
||||
|
||||
return Buffer.from(encryptedExport, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入 share
|
||||
*/
|
||||
importShare(data: Buffer, password: string): ShareEntry {
|
||||
const encryptedExport = data.toString('utf8');
|
||||
|
||||
// 解密导出数据
|
||||
const decrypted = this.decrypt(encryptedExport, password);
|
||||
const exportData = JSON.parse(decrypted);
|
||||
|
||||
if (!exportData.version || !exportData.share) {
|
||||
throw new Error('Invalid export file format');
|
||||
}
|
||||
|
||||
// 解密 share 数据
|
||||
const rawShare = this.decrypt(exportData.share.encryptedShare, password);
|
||||
|
||||
// 检查是否已存在相同的 share
|
||||
const shares = this.store.get('shares', []);
|
||||
const existing = shares.find(
|
||||
(s) => s.sessionId === exportData.share.sessionId && s.partyId === exportData.share.partyId
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Share already exists');
|
||||
}
|
||||
|
||||
// 保存导入的 share
|
||||
return this.saveShare(
|
||||
{
|
||||
sessionId: exportData.share.sessionId,
|
||||
walletName: exportData.share.walletName,
|
||||
partyId: exportData.share.partyId,
|
||||
partyIndex: exportData.share.partyIndex,
|
||||
threshold: exportData.share.threshold,
|
||||
publicKey: exportData.share.publicKey,
|
||||
metadata: exportData.share.metadata,
|
||||
rawShare,
|
||||
},
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设置
|
||||
*/
|
||||
getSettings() {
|
||||
return this.store.get('settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设置
|
||||
*/
|
||||
updateSettings(settings: Partial<StoreSchema['settings']>): void {
|
||||
const current = this.store.get('settings');
|
||||
this.store.set('settings', { ...current, ...settings });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import { GrpcClient } from './grpc-client';
|
||||
|
||||
/**
|
||||
* TSS 协议处理结果
|
||||
*/
|
||||
export interface KeygenResult {
|
||||
success: boolean;
|
||||
publicKey: Buffer;
|
||||
encryptedShare: Buffer;
|
||||
partyIndex: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SignResult {
|
||||
success: boolean;
|
||||
signature: Buffer;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TSS 消息处理器接口
|
||||
*/
|
||||
interface TSSMessage {
|
||||
type: 'outgoing' | 'result' | 'error' | 'progress';
|
||||
isBroadcast?: boolean;
|
||||
toParties?: string[];
|
||||
payload?: string; // base64 encoded
|
||||
publicKey?: string; // base64 encoded
|
||||
encryptedShare?: string; // base64 encoded
|
||||
partyIndex?: number;
|
||||
round?: number;
|
||||
totalRounds?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话参与者信息
|
||||
*/
|
||||
interface ParticipantInfo {
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TSS 协议处理器
|
||||
*
|
||||
* 使用子进程方式运行 Go 编写的 TSS 协议实现
|
||||
* 这种方式比 WASM 更可靠,特别是对于复杂的密码学操作
|
||||
*/
|
||||
export class TSSHandler extends EventEmitter {
|
||||
private tssProcess: ChildProcess | null = null;
|
||||
private grpcClient: GrpcClient;
|
||||
private sessionId: string | null = null;
|
||||
private partyId: string | null = null;
|
||||
private partyIndex: number = -1;
|
||||
private participants: ParticipantInfo[] = [];
|
||||
private partyIndexMap: Map<string, number> = new Map();
|
||||
private isRunning = false;
|
||||
|
||||
constructor(grpcClient: GrpcClient) {
|
||||
super();
|
||||
this.grpcClient = grpcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TSS 二进制文件路径
|
||||
*/
|
||||
private getTSSBinaryPath(): string {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let binaryName = 'tss-party';
|
||||
if (platform === 'win32') {
|
||||
binaryName += '.exe';
|
||||
}
|
||||
|
||||
// 开发环境: 在项目目录下
|
||||
const devPath = path.join(__dirname, '../../bin', `${platform}-${arch}`, binaryName);
|
||||
if (fs.existsSync(devPath)) {
|
||||
return devPath;
|
||||
}
|
||||
|
||||
// 生产环境: 在 app 资源目录下
|
||||
const prodPath = path.join(process.resourcesPath, 'bin', binaryName);
|
||||
if (fs.existsSync(prodPath)) {
|
||||
return prodPath;
|
||||
}
|
||||
|
||||
// 回退: 期望在 PATH 中
|
||||
return binaryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与 Keygen 协议
|
||||
*/
|
||||
async participateKeygen(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
partyIndex: number,
|
||||
participants: ParticipantInfo[],
|
||||
threshold: { t: number; n: number },
|
||||
encryptionPassword: string
|
||||
): Promise<KeygenResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('TSS protocol already running');
|
||||
}
|
||||
|
||||
this.sessionId = sessionId;
|
||||
this.partyId = partyId;
|
||||
this.partyIndex = partyIndex;
|
||||
this.participants = participants;
|
||||
this.isRunning = true;
|
||||
|
||||
// 构建 party index map
|
||||
this.partyIndexMap.clear();
|
||||
for (const p of participants) {
|
||||
this.partyIndexMap.set(p.partyId, p.partyIndex);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const binaryPath = this.getTSSBinaryPath();
|
||||
|
||||
// 构建参与者列表 JSON
|
||||
const participantsJson = JSON.stringify(participants);
|
||||
|
||||
// 启动 TSS 子进程
|
||||
this.tssProcess = spawn(binaryPath, [
|
||||
'keygen',
|
||||
'--session-id', sessionId,
|
||||
'--party-id', partyId,
|
||||
'--party-index', partyIndex.toString(),
|
||||
'--threshold-t', threshold.t.toString(),
|
||||
'--threshold-n', threshold.n.toString(),
|
||||
'--participants', participantsJson,
|
||||
'--password', encryptionPassword,
|
||||
]);
|
||||
|
||||
let resultData = '';
|
||||
|
||||
// 处理标准输出 (JSON 消息)
|
||||
this.tssProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const message: TSSMessage = JSON.parse(line);
|
||||
this.handleTSSMessage(message);
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultData = line;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 输出,记录日志
|
||||
console.log('[TSS]', line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理标准错误
|
||||
this.tssProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[TSS Error]', data.toString());
|
||||
});
|
||||
|
||||
// 处理进程退出
|
||||
this.tssProcess.on('close', (code) => {
|
||||
this.isRunning = false;
|
||||
this.tssProcess = null;
|
||||
|
||||
if (code === 0 && resultData) {
|
||||
try {
|
||||
const result: TSSMessage = JSON.parse(resultData);
|
||||
if (result.publicKey && result.encryptedShare) {
|
||||
resolve({
|
||||
success: true,
|
||||
publicKey: Buffer.from(result.publicKey, 'base64'),
|
||||
encryptedShare: Buffer.from(result.encryptedShare, 'base64'),
|
||||
partyIndex: result.partyIndex || partyIndex,
|
||||
});
|
||||
} else {
|
||||
reject(new Error(result.error || 'Keygen failed: no result data'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse keygen result: ${e}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Keygen process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// 处理进程错误
|
||||
this.tssProcess.on('error', (err) => {
|
||||
this.isRunning = false;
|
||||
this.tssProcess = null;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 订阅 MPC 消息并转发给 TSS 进程
|
||||
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||
|
||||
} catch (err) {
|
||||
this.isRunning = false;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 TSS 进程发出的消息
|
||||
*/
|
||||
private async handleTSSMessage(message: TSSMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'outgoing':
|
||||
// TSS 进程要发送消息给其他参与者
|
||||
if (message.payload && this.sessionId && this.partyId) {
|
||||
const payload = Buffer.from(message.payload, 'base64');
|
||||
const toParties = message.isBroadcast ? [] : (message.toParties || []);
|
||||
|
||||
try {
|
||||
await this.grpcClient.routeMessage(
|
||||
this.sessionId,
|
||||
this.partyId,
|
||||
toParties,
|
||||
0, // round number handled by TSS lib
|
||||
payload
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to route TSS message:', err);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
// 进度更新
|
||||
this.emit('progress', {
|
||||
round: message.round,
|
||||
totalRounds: message.totalRounds,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.emit('error', new Error(message.error));
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
// 结果会在 close 事件中处理
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理从 gRPC 接收的 MPC 消息
|
||||
*/
|
||||
private handleIncomingMessage(message: {
|
||||
fromParty: string;
|
||||
isBroadcast: boolean;
|
||||
payload: Buffer;
|
||||
}): void {
|
||||
if (!this.tssProcess || !this.tssProcess.stdin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromIndex = this.partyIndexMap.get(message.fromParty);
|
||||
if (fromIndex === undefined) {
|
||||
console.warn('Received message from unknown party:', message.fromParty);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送消息给 TSS 进程
|
||||
const inputMessage = JSON.stringify({
|
||||
type: 'incoming',
|
||||
fromPartyIndex: fromIndex,
|
||||
isBroadcast: message.isBroadcast,
|
||||
payload: message.payload.toString('base64'),
|
||||
});
|
||||
|
||||
this.tssProcess.stdin.write(inputMessage + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消正在进行的协议
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this.tssProcess) {
|
||||
this.tssProcess.kill('SIGTERM');
|
||||
this.tssProcess = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.grpcClient.removeAllListeners('mpcMessage');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在运行
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 TSS Handler
|
||||
*
|
||||
* 用于开发和测试,不需要实际的 TSS 二进制文件
|
||||
* 模拟 keygen 过程并生成假的密钥数据
|
||||
*/
|
||||
export class MockTSSHandler extends EventEmitter {
|
||||
private grpcClient: GrpcClient;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(grpcClient: GrpcClient) {
|
||||
super();
|
||||
this.grpcClient = grpcClient;
|
||||
}
|
||||
|
||||
async participateKeygen(
|
||||
sessionId: string,
|
||||
partyId: string,
|
||||
partyIndex: number,
|
||||
participants: ParticipantInfo[],
|
||||
threshold: { t: number; n: number },
|
||||
encryptionPassword: string
|
||||
): Promise<KeygenResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('TSS protocol already running');
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// 模拟 keygen 过程
|
||||
const totalRounds = 4;
|
||||
|
||||
for (let round = 1; round <= totalRounds; round++) {
|
||||
// 每轮等待 1-2 秒
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000));
|
||||
|
||||
this.emit('progress', {
|
||||
round,
|
||||
totalRounds,
|
||||
});
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
// 生成模拟的密钥数据
|
||||
const mockPublicKey = Buffer.alloc(33);
|
||||
mockPublicKey[0] = 0x02; // compressed public key prefix
|
||||
for (let i = 1; i < 33; i++) {
|
||||
mockPublicKey[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
|
||||
const mockShareData = Buffer.from(JSON.stringify({
|
||||
sessionId,
|
||||
partyId,
|
||||
partyIndex,
|
||||
threshold,
|
||||
participants: participants.map(p => ({ partyId: p.partyId, partyIndex: p.partyIndex })),
|
||||
createdAt: new Date().toISOString(),
|
||||
// 实际的 share 数据会更复杂
|
||||
shareSecret: Buffer.alloc(32).fill(partyIndex).toString('hex'),
|
||||
}));
|
||||
|
||||
// 简单的 "加密" (实际应该使用 AES-256-GCM)
|
||||
const encryptedShare = Buffer.concat([
|
||||
Buffer.from('MOCK_ENCRYPTED:'),
|
||||
mockShareData,
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
publicKey: mockPublicKey,
|
||||
encryptedShare,
|
||||
partyIndex,
|
||||
};
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// gRPC 相关
|
||||
grpc: {
|
||||
connect: (host: string, port: number) =>
|
||||
ipcRenderer.invoke('grpc:connect', { host, port }),
|
||||
register: (partyId: string, role: string) =>
|
||||
ipcRenderer.invoke('grpc:register', { partyId, role }),
|
||||
joinSession: (sessionId: string, partyId: string, joinToken: string) =>
|
||||
ipcRenderer.invoke('grpc:joinSession', { sessionId, partyId, joinToken }),
|
||||
},
|
||||
|
||||
// 存储相关
|
||||
storage: {
|
||||
saveShare: (share: unknown, password: string) =>
|
||||
ipcRenderer.invoke('storage:saveShare', { share, password }),
|
||||
listShares: () =>
|
||||
ipcRenderer.invoke('storage:listShares'),
|
||||
exportShare: (id: string, password: string) =>
|
||||
ipcRenderer.invoke('storage:exportShare', { id, password }),
|
||||
importShare: (data: Buffer, password: string) =>
|
||||
ipcRenderer.invoke('storage:importShare', { data, password }),
|
||||
},
|
||||
|
||||
// 事件监听
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'session:event',
|
||||
'session:message',
|
||||
'session:progress',
|
||||
'session:completed',
|
||||
'session:error',
|
||||
];
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.on(channel, (_event, ...args) => callback(...args));
|
||||
}
|
||||
},
|
||||
|
||||
// 移除事件监听
|
||||
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
ipcRenderer.removeListener(channel, callback);
|
||||
},
|
||||
|
||||
// 平台信息
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
// 类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
grpc: {
|
||||
connect: (host: string, port: number) => Promise<{ success: boolean; error?: string }>;
|
||||
register: (partyId: string, role: string) => Promise<{ success: boolean; error?: string }>;
|
||||
joinSession: (sessionId: string, partyId: string, joinToken: string) => Promise<{ success: boolean; data?: unknown; error?: string }>;
|
||||
};
|
||||
storage: {
|
||||
saveShare: (share: unknown, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
listShares: () => Promise<{ success: boolean; data?: unknown[]; error?: string }>;
|
||||
exportShare: (id: string, password: string) => Promise<{ success: boolean; data?: Buffer; error?: string }>;
|
||||
importShare: (data: Buffer, password: string) => Promise<{ success: boolean; data?: unknown; error?: string }>;
|
||||
};
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RWADurian Service Party</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "service-party-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Multi-party co-managed wallet participant application",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||
"dev:vite": "vite",
|
||||
"dev:electron": "wait-on http://localhost:5173 && electron .",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"build:win": "npm run build -- --win",
|
||||
"build:mac": "npm run build -- --mac",
|
||||
"build:linux": "npm run build -- --linux",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"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": {
|
||||
"@grpc/grpc-js": "^1.9.0",
|
||||
"@grpc/proto-loader": "^0.7.10",
|
||||
"electron-store": "^8.1.0",
|
||||
"express": "^4.18.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"zustand": "^4.4.7",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"grpc-tools": "^1.12.4",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.2",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.rwadurian.service-party",
|
||||
"productName": "RWADurian Service Party",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"wasm/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "resources/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"icon": "resources/icon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Home } from './pages/Home';
|
||||
import { Join } from './pages/Join';
|
||||
import { Create } from './pages/Create';
|
||||
import { Session } from './pages/Session';
|
||||
import { Settings } from './pages/Settings';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/join" element={<Join />} />
|
||||
<Route path="/join/:inviteCode" element={<Join />} />
|
||||
<Route path="/create" element={<Create />} />
|
||||
<Route path="/session/:sessionId" element={<Session />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--surface-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navList {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navItemActive:hover {
|
||||
background-color: var(--primary-light);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.navLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.connectionStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.statusDot[data-status="connected"] {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
.statusDot[data-status="connecting"] {
|
||||
background-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.statusDot[data-status="disconnected"] {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import styles from './Layout.module.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '我的钱包', icon: '🔐' },
|
||||
{ path: '/create', label: '创建钱包', icon: '➕' },
|
||||
{ path: '/join', label: '加入创建', icon: '🤝' },
|
||||
{ path: '/settings', label: '设置', icon: '⚙️' },
|
||||
];
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<nav className={styles.sidebar}>
|
||||
<div className={styles.logo}>
|
||||
<span className={styles.logoIcon}>🔑</span>
|
||||
<span className={styles.logoText}>Service Party</span>
|
||||
</div>
|
||||
<ul className={styles.navList}>
|
||||
{navItems.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`${styles.navItem} ${
|
||||
location.pathname === item.path ? styles.navItemActive : ''
|
||||
}`}
|
||||
>
|
||||
<span className={styles.navIcon}>{item.icon}</span>
|
||||
<span className={styles.navLabel}>{item.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.connectionStatus}>
|
||||
<span className={styles.statusDot} data-status="connected" />
|
||||
<span>已连接</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className={styles.main}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--spacing-xl);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.thresholdConfig {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.thresholdItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.thresholdLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.thresholdDivider {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.numberInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.numberButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.numberButton:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.numberButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.creating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
margin: 0 auto var(--spacing-md);
|
||||
}
|
||||
|
||||
.successTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.inviteSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.inviteCodeWrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.inviteCode {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './Create.module.css';
|
||||
|
||||
interface CreateSessionResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
inviteCode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Create() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [walletName, setWalletName] = useState('');
|
||||
const [thresholdT, setThresholdT] = useState(2);
|
||||
const [thresholdN, setThresholdN] = useState(3);
|
||||
const [participantName, setParticipantName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<CreateSessionResult | null>(null);
|
||||
const [step, setStep] = useState<'config' | 'creating' | 'created'>('config');
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
if (!walletName.trim()) {
|
||||
setError('请输入钱包名称');
|
||||
return;
|
||||
}
|
||||
if (!participantName.trim()) {
|
||||
setError('请输入您的名称');
|
||||
return;
|
||||
}
|
||||
if (thresholdT > thresholdN) {
|
||||
setError('签名阈值不能大于参与方总数');
|
||||
return;
|
||||
}
|
||||
if (thresholdT < 1) {
|
||||
setError('签名阈值至少为 1');
|
||||
return;
|
||||
}
|
||||
if (thresholdN < 2) {
|
||||
setError('参与方总数至少为 2');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('creating');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const createResult = await window.electronAPI.grpc.createSession({
|
||||
walletName: walletName.trim(),
|
||||
thresholdT,
|
||||
thresholdN,
|
||||
initiatorName: participantName.trim(),
|
||||
});
|
||||
|
||||
if (createResult.success) {
|
||||
setResult(createResult);
|
||||
setStep('created');
|
||||
} else {
|
||||
setError(createResult.error || '创建会话失败');
|
||||
setStep('config');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('创建会话失败,请检查网络连接');
|
||||
setStep('config');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteCode = async () => {
|
||||
if (result?.inviteCode) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.inviteCode);
|
||||
// 可以添加复制成功的提示
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToSession = () => {
|
||||
if (result?.sessionId) {
|
||||
navigate(`/session/${result.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>创建共管钱包</h1>
|
||||
<p className={styles.subtitle}>设置钱包参数并邀请其他参与方</p>
|
||||
|
||||
{step === 'config' && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>钱包名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walletName}
|
||||
onChange={(e) => setWalletName(e.target.value)}
|
||||
placeholder="为您的共管钱包命名"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>阈值设置 (T-of-N)</label>
|
||||
<div className={styles.thresholdConfig}>
|
||||
<div className={styles.thresholdItem}>
|
||||
<span className={styles.thresholdLabel}>签名阈值 (T)</span>
|
||||
<div className={styles.numberInput}>
|
||||
<button
|
||||
className={styles.numberButton}
|
||||
onClick={() => setThresholdT(Math.max(1, thresholdT - 1))}
|
||||
disabled={thresholdT <= 1}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className={styles.numberValue}>{thresholdT}</span>
|
||||
<button
|
||||
className={styles.numberButton}
|
||||
onClick={() => setThresholdT(Math.min(thresholdN, thresholdT + 1))}
|
||||
disabled={thresholdT >= thresholdN}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.thresholdDivider}>of</div>
|
||||
<div className={styles.thresholdItem}>
|
||||
<span className={styles.thresholdLabel}>参与方总数 (N)</span>
|
||||
<div className={styles.numberInput}>
|
||||
<button
|
||||
className={styles.numberButton}
|
||||
onClick={() => {
|
||||
const newN = Math.max(2, thresholdN - 1);
|
||||
setThresholdN(newN);
|
||||
if (thresholdT > newN) setThresholdT(newN);
|
||||
}}
|
||||
disabled={thresholdN <= 2}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className={styles.numberValue}>{thresholdN}</span>
|
||||
<button
|
||||
className={styles.numberButton}
|
||||
onClick={() => setThresholdN(thresholdN + 1)}
|
||||
disabled={thresholdN >= 10}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
需要 {thresholdT} 个参与方共同签名才能执行交易
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>您的名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
placeholder="输入您的名称(其他参与者可见)"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateSession}
|
||||
disabled={isLoading}
|
||||
>
|
||||
创建会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'creating' && (
|
||||
<div className={styles.creating}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>正在创建会话...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'created' && result && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.successIcon}>✓</div>
|
||||
<h3 className={styles.successTitle}>会话创建成功</h3>
|
||||
|
||||
<div className={styles.inviteSection}>
|
||||
<label className={styles.label}>邀请码</label>
|
||||
<div className={styles.inviteCodeWrapper}>
|
||||
<code className={styles.inviteCode}>{result.inviteCode}</code>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopyInviteCode}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
将此邀请码分享给其他参与方,他们可以使用此码加入会话
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleGoToSession}
|
||||
>
|
||||
进入会话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl) * 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 64px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.emptyActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.primaryButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background-color: var(--background-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.threshold {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.infoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 90, 156, 0.05);
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './Home.module.css';
|
||||
|
||||
interface ShareItem {
|
||||
id: string;
|
||||
walletName: string;
|
||||
publicKey: string;
|
||||
threshold: { t: number; n: number };
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
metadata: {
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate();
|
||||
const [shares, setShares] = useState<ShareItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
}, []);
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
// 检测是否在 Electron 环境中
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.storage.listShares();
|
||||
if (result.success) {
|
||||
setShares(result.data as ShareItem[]);
|
||||
}
|
||||
} else {
|
||||
// 浏览器环境,使用 localStorage 或 API
|
||||
const stored = localStorage.getItem('shares');
|
||||
if (stored) {
|
||||
setShares(JSON.parse(stored));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load shares:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (id: string) => {
|
||||
const password = window.prompt('请输入密码以导出备份文件:');
|
||||
if (!password) return;
|
||||
|
||||
try {
|
||||
if (window.electronAPI) {
|
||||
const result = await window.electronAPI.storage.exportShare(id, password);
|
||||
if (result.success && result.data) {
|
||||
// 触发下载
|
||||
const blob = new Blob([result.data], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `share-backup-${id.slice(0, 8)}.dat`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('导出失败: ' + (error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const truncateKey = (key: string) => {
|
||||
if (key.length > 16) {
|
||||
return `${key.slice(0, 8)}...${key.slice(-8)}`;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner} />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>我的共管钱包</h1>
|
||||
<p className={styles.subtitle}>管理您参与的多方共管钱包</p>
|
||||
</header>
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>🔐</div>
|
||||
<h2 className={styles.emptyTitle}>暂无共管钱包</h2>
|
||||
<p className={styles.emptyText}>
|
||||
您可以创建新的共管钱包,或加入他人发起的钱包创建
|
||||
</p>
|
||||
<div className={styles.emptyActions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => navigate('/create')}
|
||||
>
|
||||
创建钱包
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/join')}
|
||||
>
|
||||
加入创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{shares.map((share) => (
|
||||
<div key={share.id} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<h3 className={styles.cardTitle}>{share.walletName}</h3>
|
||||
<span className={styles.threshold}>
|
||||
{share.threshold.t}-of-{share.threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>公钥</span>
|
||||
<code className={styles.infoValue}>
|
||||
{truncateKey(share.publicKey)}
|
||||
</code>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>参与方</span>
|
||||
<span className={styles.infoValue}>
|
||||
{share.metadata.participants.length} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>创建时间</span>
|
||||
<span className={styles.infoValue}>
|
||||
{formatDate(share.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{share.lastUsedAt && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>上次使用</span>
|
||||
<span className={styles.infoValue}>
|
||||
{formatDate(share.lastUsedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardFooter}>
|
||||
<button
|
||||
className={styles.actionButton}
|
||||
onClick={() => handleExport(share.id)}
|
||||
>
|
||||
导出备份
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--spacing-xl);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pasteButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pasteButton:hover:not(:disabled) {
|
||||
background-color: var(--surface-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pasteButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sessionInfo {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sessionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.joining {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import styles from './Join.module.css';
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
threshold: { t: number; n: number };
|
||||
initiator: string;
|
||||
createdAt: string;
|
||||
currentParticipants: number;
|
||||
}
|
||||
|
||||
export default function Join() {
|
||||
const { inviteCode } = useParams<{ inviteCode?: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [code, setCode] = useState(inviteCode || '');
|
||||
const [participantName, setParticipantName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||
const [step, setStep] = useState<'input' | 'confirm' | 'joining'>('input');
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteCode) {
|
||||
handleValidateCode(inviteCode);
|
||||
}
|
||||
}, [inviteCode]);
|
||||
|
||||
const handleValidateCode = async (codeToValidate: string) => {
|
||||
if (!codeToValidate.trim()) {
|
||||
setError('请输入邀请码');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 解析邀请码获取会话信息
|
||||
const result = await window.electronAPI.grpc.validateInviteCode(codeToValidate);
|
||||
if (result.success) {
|
||||
setSessionInfo(result.sessionInfo);
|
||||
setStep('confirm');
|
||||
} else {
|
||||
setError(result.error || '无效的邀请码');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('验证邀请码失败,请检查网络连接');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoinSession = async () => {
|
||||
if (!sessionInfo || !participantName.trim()) {
|
||||
setError('请输入参与者名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('joining');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.grpc.joinSession(
|
||||
sessionInfo.sessionId,
|
||||
participantName.trim()
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
navigate(`/session/${sessionInfo.sessionId}`);
|
||||
} else {
|
||||
setError(result.error || '加入会话失败');
|
||||
setStep('confirm');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('加入会话失败,请重试');
|
||||
setStep('confirm');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setCode(text.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to read clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<h1 className={styles.title}>加入共管钱包</h1>
|
||||
<p className={styles.subtitle}>输入邀请码或扫描二维码加入</p>
|
||||
|
||||
{step === 'input' && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>邀请码</label>
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="粘贴邀请码或邀请链接"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={styles.pasteButton}
|
||||
onClick={handlePaste}
|
||||
disabled={isLoading}
|
||||
>
|
||||
粘贴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => handleValidateCode(code)}
|
||||
disabled={isLoading || !code.trim()}
|
||||
>
|
||||
{isLoading ? '验证中...' : '下一步'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && sessionInfo && (
|
||||
<div className={styles.form}>
|
||||
<div className={styles.sessionInfo}>
|
||||
<h3 className={styles.sessionTitle}>会话信息</h3>
|
||||
<div className={styles.infoGrid}>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>钱包名称</span>
|
||||
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>阈值设置</span>
|
||||
<span className={styles.infoValue}>
|
||||
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>发起者</span>
|
||||
<span className={styles.infoValue}>{sessionInfo.initiator}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>当前参与者</span>
|
||||
<span className={styles.infoValue}>
|
||||
{sessionInfo.currentParticipants} / {sessionInfo.threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label className={styles.label}>您的名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
placeholder="输入您的名称(其他参与者可见)"
|
||||
className={styles.input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => {
|
||||
setStep('input');
|
||||
setSessionInfo(null);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleJoinSession}
|
||||
disabled={isLoading || !participantName.trim()}
|
||||
>
|
||||
{isLoading ? '加入中...' : '确认加入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'joining' && (
|
||||
<div className={styles.joining}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>正在加入会话...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--background-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.sessionId {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusWaiting {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
color: #f0ad4e;
|
||||
}
|
||||
|
||||
.statusReady {
|
||||
background-color: rgba(23, 162, 184, 0.15);
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.statusProcessing {
|
||||
background-color: rgba(0, 123, 255, 0.15);
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.statusCompleted {
|
||||
background-color: rgba(40, 167, 69, 0.15);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
background-color: rgba(220, 53, 69, 0.15);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.progress {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.progressHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 8px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.participantList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.participant {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.participantEmpty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.participantInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.participantIndex {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.participantName {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.participantStatus {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.thresholdInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.thresholdBadge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thresholdText {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.publicKeyWrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.publicKey {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.successMessage {
|
||||
font-size: 13px;
|
||||
color: var(--success-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.failureMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.failureIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.error h3 {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.error p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import styles from './Session.module.css';
|
||||
|
||||
interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
threshold: { t: number; n: number };
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
participants: Participant[];
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Session() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSessionStatus = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.grpc.getSessionStatus(sessionId);
|
||||
if (result.success) {
|
||||
setSession(result.session);
|
||||
} else {
|
||||
setError(result.error || '获取会话状态失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('获取会话状态失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessionStatus();
|
||||
|
||||
// 订阅会话事件
|
||||
const unsubscribe = window.electronAPI.grpc.subscribeSessionEvents(
|
||||
sessionId!,
|
||||
(event: any) => {
|
||||
if (event.type === 'participant_joined') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
participants: [...prev.participants, event.participant]
|
||||
} : null);
|
||||
} else if (event.type === 'status_changed') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: event.status,
|
||||
currentRound: event.currentRound ?? prev.currentRound,
|
||||
} : null);
|
||||
} else if (event.type === 'completed') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'completed',
|
||||
publicKey: event.publicKey,
|
||||
} : null);
|
||||
} else if (event.type === 'failed') {
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'failed',
|
||||
error: event.error,
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [sessionId, fetchSessionStatus]);
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return '等待参与方';
|
||||
case 'ready': return '准备就绪';
|
||||
case 'processing': return '密钥生成中';
|
||||
case 'completed': return '已完成';
|
||||
case 'failed': return '失败';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return styles.statusWaiting;
|
||||
case 'ready': return styles.statusReady;
|
||||
case 'processing': return styles.statusProcessing;
|
||||
case 'completed': return styles.statusCompleted;
|
||||
case 'failed': return styles.statusFailed;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getParticipantStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return '⏳';
|
||||
case 'ready': return '✓';
|
||||
case 'processing': return '⚡';
|
||||
case 'completed': return '✓';
|
||||
case 'failed': return '✗';
|
||||
default: return '•';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPublicKey = async () => {
|
||||
if (session?.publicKey) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(session.publicKey);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>加载会话信息...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.error}>
|
||||
<div className={styles.errorIcon}>!</div>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error || '无法获取会话信息'}</p>
|
||||
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{session.walletName}</h1>
|
||||
<p className={styles.sessionId}>会话 ID: {session.sessionId}</p>
|
||||
</div>
|
||||
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
|
||||
{getStatusText(session.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* 进度部分 */}
|
||||
{session.status === 'processing' && (
|
||||
<div className={styles.progress}>
|
||||
<div className={styles.progressHeader}>
|
||||
<span>密钥生成进度</span>
|
||||
<span>{session.currentRound} / {session.totalRounds}</span>
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参与方列表 */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>
|
||||
参与方 ({session.participants.length} / {session.threshold.n})
|
||||
</h3>
|
||||
<div className={styles.participantList}>
|
||||
{session.participants.map((participant, index) => (
|
||||
<div key={participant.partyId} className={styles.participant}>
|
||||
<div className={styles.participantInfo}>
|
||||
<span className={styles.participantIndex}>#{index + 1}</span>
|
||||
<span className={styles.participantName}>{participant.name}</span>
|
||||
</div>
|
||||
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
|
||||
{getParticipantStatusIcon(participant.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: session.threshold.n - session.participants.length }).map((_, index) => (
|
||||
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
|
||||
<div className={styles.participantInfo}>
|
||||
<span className={styles.participantIndex}>#{session.participants.length + index + 1}</span>
|
||||
<span className={styles.participantName}>等待加入...</span>
|
||||
</div>
|
||||
<span className={styles.participantStatus}>⏳</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 阈值信息 */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>阈值设置</h3>
|
||||
<div className={styles.thresholdInfo}>
|
||||
<span className={styles.thresholdBadge}>
|
||||
{session.threshold.t}-of-{session.threshold.n}
|
||||
</span>
|
||||
<span className={styles.thresholdText}>
|
||||
需要 {session.threshold.t} 个参与方共同签名
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成状态 */}
|
||||
{session.status === 'completed' && session.publicKey && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>钱包公钥</h3>
|
||||
<div className={styles.publicKeyWrapper}>
|
||||
<code className={styles.publicKey}>{session.publicKey}</code>
|
||||
<button className={styles.copyButton} onClick={handleCopyPublicKey}>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.successMessage}>
|
||||
✓ 密钥份额已安全保存到本地
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态 */}
|
||||
{session.status === 'failed' && session.error && (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.failureMessage}>
|
||||
<span className={styles.failureIcon}>!</span>
|
||||
<span>{session.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{session.status === 'completed' ? (
|
||||
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
) : session.status === 'failed' ? (
|
||||
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
) : (
|
||||
<button className={styles.secondaryButton} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.labelDanger {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.inputWithButton {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.input:read-only {
|
||||
background-color: var(--background-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.testButton,
|
||||
.browseButton {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.testButton:hover,
|
||||
.browseButton:hover {
|
||||
background-color: var(--surface-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.checkboxField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
align-self: flex-start;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
align-self: flex-start;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
color: var(--error-color);
|
||||
border: 1px solid var(--error-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dangerButton:hover {
|
||||
background-color: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.aboutInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.aboutItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.aboutItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.aboutLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.aboutValue {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: rgba(40, 167, 69, 0.1);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styles from './Settings.module.css';
|
||||
|
||||
interface Settings {
|
||||
messageRouterUrl: string;
|
||||
autoBackup: boolean;
|
||||
backupPath: string;
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
messageRouterUrl: 'localhost:50051',
|
||||
autoBackup: false,
|
||||
backupPath: '',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.storage.getSettings();
|
||||
if (result) {
|
||||
setSettings(result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
await window.electronAPI.storage.saveSettings(settings);
|
||||
setMessage({ type: 'success', text: '设置已保存' });
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '保存设置失败' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.grpc.testConnection(settings.messageRouterUrl);
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: '连接成功' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || '连接失败' });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: 'error', text: '连接测试失败' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectBackupPath = async () => {
|
||||
try {
|
||||
const path = await window.electronAPI.dialog.selectDirectory();
|
||||
if (path) {
|
||||
setSettings(prev => ({ ...prev, backupPath: path }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to select directory:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>加载设置...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>设置</h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* 连接设置 */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>连接设置</h2>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Message Router 地址</label>
|
||||
<div className={styles.inputWithButton}>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.messageRouterUrl}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, messageRouterUrl: e.target.value }))}
|
||||
placeholder="localhost:50051"
|
||||
className={styles.input}
|
||||
/>
|
||||
<button
|
||||
className={styles.testButton}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
输入 Message Router 服务的 gRPC 地址
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 备份设置 */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>备份设置</h2>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.field}>
|
||||
<div className={styles.checkboxField}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoBackup"
|
||||
checked={settings.autoBackup}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, autoBackup: e.target.checked }))}
|
||||
className={styles.checkbox}
|
||||
/>
|
||||
<label htmlFor="autoBackup" className={styles.checkboxLabel}>
|
||||
自动备份密钥份额
|
||||
</label>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
创建新的密钥份额后自动备份到指定目录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings.autoBackup && (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>备份目录</label>
|
||||
<div className={styles.inputWithButton}>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.backupPath}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, backupPath: e.target.value }))}
|
||||
placeholder="选择备份目录"
|
||||
className={styles.input}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
className={styles.browseButton}
|
||||
onClick={handleSelectBackupPath}
|
||||
>
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 数据管理 */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>数据管理</h2>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>导出所有数据</label>
|
||||
<p className={styles.hint}>
|
||||
将所有密钥份额导出为加密文件
|
||||
</p>
|
||||
<button className={styles.actionButton}>
|
||||
导出数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}></div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>导入数据</label>
|
||||
<p className={styles.hint}>
|
||||
从加密备份文件导入密钥份额
|
||||
</p>
|
||||
<button className={styles.actionButton}>
|
||||
导入数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.divider}></div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.labelDanger}>清除所有数据</label>
|
||||
<p className={styles.hint}>
|
||||
删除所有本地存储的密钥份额。此操作不可恢复!
|
||||
</p>
|
||||
<button className={styles.dangerButton}>
|
||||
清除数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 关于 */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>关于</h2>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.aboutInfo}>
|
||||
<div className={styles.aboutItem}>
|
||||
<span className={styles.aboutLabel}>应用名称</span>
|
||||
<span className={styles.aboutValue}>Service Party</span>
|
||||
</div>
|
||||
<div className={styles.aboutItem}>
|
||||
<span className={styles.aboutLabel}>版本</span>
|
||||
<span className={styles.aboutValue}>1.0.0</span>
|
||||
</div>
|
||||
<div className={styles.aboutItem}>
|
||||
<span className={styles.aboutLabel}>项目</span>
|
||||
<span className={styles.aboutValue}>RWADurian MPC System</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{message && (
|
||||
<div className={`${styles.message} ${styles[message.type]}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 主色系 */
|
||||
--primary-color: #005a9c;
|
||||
--primary-light: #1a6fad;
|
||||
--primary-dark: #004a7f;
|
||||
|
||||
/* 语义色 */
|
||||
--success-color: #4caf50;
|
||||
--warning-color: #ff9800;
|
||||
--error-color: #f44336;
|
||||
--info-color: #2196f3;
|
||||
|
||||
/* 中性色 */
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-disabled: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--background-color: #f8fafc;
|
||||
--surface-color: #ffffff;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 字体 */
|
||||
--font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font-family);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
// Electron API 类型定义
|
||||
|
||||
interface ShareEntry {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
threshold: { t: number; n: number };
|
||||
publicKey: string;
|
||||
encryptedShare: string;
|
||||
createdAt: string;
|
||||
metadata: {
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
threshold: { t: number; n: number };
|
||||
initiator: string;
|
||||
createdAt: string;
|
||||
currentParticipants: number;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
threshold: { t: number; n: number };
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
participants: Participant[];
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
messageRouterUrl: string;
|
||||
autoBackup: boolean;
|
||||
backupPath: string;
|
||||
}
|
||||
|
||||
interface CreateSessionParams {
|
||||
walletName: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
initiatorName: string;
|
||||
}
|
||||
|
||||
interface CreateSessionResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
inviteCode?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface JoinSessionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ValidateInviteCodeResult {
|
||||
success: boolean;
|
||||
sessionInfo?: SessionInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GetSessionStatusResult {
|
||||
success: boolean;
|
||||
session?: SessionState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SessionEvent {
|
||||
type: 'participant_joined' | 'status_changed' | 'completed' | 'failed';
|
||||
participant?: Participant;
|
||||
status?: string;
|
||||
currentRound?: number;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ElectronAPI {
|
||||
grpc: {
|
||||
createSession: (params: CreateSessionParams) => Promise<CreateSessionResult>;
|
||||
joinSession: (sessionId: string, participantName: string) => Promise<JoinSessionResult>;
|
||||
validateInviteCode: (code: string) => Promise<ValidateInviteCodeResult>;
|
||||
getSessionStatus: (sessionId: string) => Promise<GetSessionStatusResult>;
|
||||
subscribeSessionEvents: (sessionId: string, callback: (event: SessionEvent) => void) => () => void;
|
||||
testConnection: (url: string) => Promise<TestConnectionResult>;
|
||||
};
|
||||
storage: {
|
||||
listShares: () => Promise<ShareEntry[]>;
|
||||
getShare: (id: string, password: string) => Promise<ShareEntry | null>;
|
||||
exportShare: (id: string, password: string) => Promise<{ success: boolean; filePath?: string; error?: string }>;
|
||||
importShare: (filePath: string, password: string) => Promise<{ success: boolean; share?: ShareEntry; error?: string }>;
|
||||
deleteShare: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getSettings: () => Promise<Settings | null>;
|
||||
saveSettings: (settings: Settings) => Promise<void>;
|
||||
};
|
||||
dialog: {
|
||||
selectDirectory: () => Promise<string | null>;
|
||||
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
|
||||
saveFile: (defaultPath?: string, filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@electron/*": ["electron/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "electron/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Build TSS Party binary for multiple platforms
|
||||
|
||||
set -e
|
||||
|
||||
OUTPUT_DIR="../electron/bin"
|
||||
MODULE_NAME="tss-party"
|
||||
|
||||
# Create output directories
|
||||
mkdir -p "$OUTPUT_DIR/linux-x64"
|
||||
mkdir -p "$OUTPUT_DIR/darwin-x64"
|
||||
mkdir -p "$OUTPUT_DIR/darwin-arm64"
|
||||
mkdir -p "$OUTPUT_DIR/win32-x64"
|
||||
|
||||
echo "Building TSS Party binary..."
|
||||
|
||||
# Build for Linux x64
|
||||
echo "Building for Linux x64..."
|
||||
GOOS=linux GOARCH=amd64 go build -o "$OUTPUT_DIR/linux-x64/$MODULE_NAME" .
|
||||
|
||||
# Build for macOS x64 (Intel)
|
||||
echo "Building for macOS x64..."
|
||||
GOOS=darwin GOARCH=amd64 go build -o "$OUTPUT_DIR/darwin-x64/$MODULE_NAME" .
|
||||
|
||||
# Build for macOS arm64 (Apple Silicon)
|
||||
echo "Building for macOS arm64..."
|
||||
GOOS=darwin GOARCH=arm64 go build -o "$OUTPUT_DIR/darwin-arm64/$MODULE_NAME" .
|
||||
|
||||
# Build for Windows x64
|
||||
echo "Building for Windows x64..."
|
||||
GOOS=windows GOARCH=amd64 go build -o "$OUTPUT_DIR/win32-x64/$MODULE_NAME.exe" .
|
||||
|
||||
echo "Build complete!"
|
||||
echo "Binaries available in $OUTPUT_DIR"
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
module github.com/rwadurian/mpc-system/services/service-party-app/tss-party
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/bnb-chain/tss-lib/v2 v2.0.2
|
||||
|
||||
require (
|
||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||
github.com/btcsuite/btcd v0.22.3 // indirect
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/ipfs/go-log v1.0.5 // indirect
|
||||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
// Package main provides the TSS party subprocess for Electron app
|
||||
//
|
||||
// This program handles TSS (Threshold Signature Scheme) protocol execution
|
||||
// It communicates with the parent Electron process via stdin/stdout using JSON messages
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||
)
|
||||
|
||||
// Message types for IPC
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
IsBroadcast bool `json:"isBroadcast,omitempty"`
|
||||
ToParties []string `json:"toParties,omitempty"`
|
||||
Payload string `json:"payload,omitempty"` // base64 encoded
|
||||
PublicKey string `json:"publicKey,omitempty"` // base64 encoded
|
||||
EncryptedShare string `json:"encryptedShare,omitempty"` // base64 encoded
|
||||
PartyIndex int `json:"partyIndex,omitempty"`
|
||||
Round int `json:"round,omitempty"`
|
||||
TotalRounds int `json:"totalRounds,omitempty"`
|
||||
FromPartyIndex int `json:"fromPartyIndex,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Participant info
|
||||
type Participant struct {
|
||||
PartyID string `json:"partyId"`
|
||||
PartyIndex int `json:"partyIndex"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Parse command
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "Usage: tss-party <command> [options]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
command := os.Args[1]
|
||||
|
||||
switch command {
|
||||
case "keygen":
|
||||
runKeygen()
|
||||
case "sign":
|
||||
runSign()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runKeygen() {
|
||||
// Parse keygen flags
|
||||
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
||||
sessionID := fs.String("session-id", "", "Session ID")
|
||||
partyID := fs.String("party-id", "", "Party ID")
|
||||
partyIndex := fs.Int("party-index", 0, "Party index (0-based)")
|
||||
thresholdT := fs.Int("threshold-t", 0, "Threshold T")
|
||||
thresholdN := fs.Int("threshold-n", 0, "Threshold N")
|
||||
participantsJSON := fs.String("participants", "[]", "Participants JSON array")
|
||||
password := fs.String("password", "", "Encryption password for share")
|
||||
|
||||
if err := fs.Parse(os.Args[2:]); err != nil {
|
||||
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
|
||||
sendError("Missing required parameters")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Parse participants
|
||||
var participants []Participant
|
||||
if err := json.Unmarshal([]byte(*participantsJSON), &participants); err != nil {
|
||||
sendError(fmt.Sprintf("Failed to parse participants: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(participants) != *thresholdN {
|
||||
sendError(fmt.Sprintf("Participant count mismatch: got %d, expected %d", len(participants), *thresholdN))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run keygen protocol
|
||||
result, err := executeKeygen(
|
||||
*sessionID,
|
||||
*partyID,
|
||||
*partyIndex,
|
||||
*thresholdT,
|
||||
*thresholdN,
|
||||
participants,
|
||||
*password,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
sendError(fmt.Sprintf("Keygen failed: %v", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Send result
|
||||
sendResult(result.PublicKey, result.EncryptedShare, *partyIndex)
|
||||
}
|
||||
|
||||
func runSign() {
|
||||
// TODO: Implement signing
|
||||
sendError("Signing not implemented yet")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
type keygenResult struct {
|
||||
PublicKey []byte
|
||||
EncryptedShare []byte
|
||||
}
|
||||
|
||||
func executeKeygen(
|
||||
sessionID, partyID string,
|
||||
partyIndex, thresholdT, thresholdN int,
|
||||
participants []Participant,
|
||||
password string,
|
||||
) (*keygenResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Handle signals for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Create TSS party IDs
|
||||
tssPartyIDs := make([]*tss.PartyID, len(participants))
|
||||
var selfTSSID *tss.PartyID
|
||||
|
||||
for i, p := range participants {
|
||||
partyKey := tss.NewPartyID(
|
||||
p.PartyID,
|
||||
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||
tss.S256(),
|
||||
)
|
||||
tssPartyIDs[i] = partyKey
|
||||
if p.PartyID == partyID {
|
||||
selfTSSID = partyKey
|
||||
}
|
||||
}
|
||||
|
||||
if selfTSSID == nil {
|
||||
return nil, fmt.Errorf("self party not found in participants")
|
||||
}
|
||||
|
||||
// Sort party IDs
|
||||
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||
|
||||
// Create peer context and parameters
|
||||
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
|
||||
|
||||
// Create channels
|
||||
outCh := make(chan tss.Message, thresholdN*10)
|
||||
endCh := make(chan *keygen.LocalPartySaveData, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
// Create local party
|
||||
localParty := keygen.NewLocalParty(params, outCh, endCh)
|
||||
|
||||
// Build party index map for incoming messages
|
||||
partyIndexMap := make(map[int]*tss.PartyID)
|
||||
for i, p := range sortedPartyIDs {
|
||||
for _, orig := range participants {
|
||||
if orig.PartyID == p.Id {
|
||||
partyIndexMap[orig.PartyIndex] = p
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = i
|
||||
}
|
||||
|
||||
// Start the local party
|
||||
go func() {
|
||||
if err := localParty.Start(); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle outgoing messages
|
||||
var outWg sync.WaitGroup
|
||||
outWg.Add(1)
|
||||
go func() {
|
||||
defer outWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-outCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handleOutgoingMessage(msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle incoming messages from stdin
|
||||
var inWg sync.WaitGroup
|
||||
inWg.Add(1)
|
||||
go func() {
|
||||
defer inWg.Done()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Type == "incoming" {
|
||||
handleIncomingMessage(msg, localParty, partyIndexMap, errCh)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Track progress
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
currentRound := 0
|
||||
|
||||
// Wait for completion
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case err := <-errCh:
|
||||
return nil, err
|
||||
case saveData := <-endCh:
|
||||
// Keygen completed successfully
|
||||
sendProgress(totalRounds, totalRounds)
|
||||
|
||||
// Get public key
|
||||
pubKey := saveData.ECDSAPub.ToECDSAPubKey()
|
||||
pubKeyBytes := make([]byte, 33)
|
||||
pubKeyBytes[0] = 0x02 + byte(pubKey.Y.Bit(0))
|
||||
xBytes := pubKey.X.Bytes()
|
||||
copy(pubKeyBytes[33-len(xBytes):], xBytes)
|
||||
|
||||
// Serialize and encrypt save data
|
||||
saveDataBytes, err := json.Marshal(saveData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize save data: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt with password (simple XOR for now - should use AES-GCM in production)
|
||||
encryptedShare := encryptShare(saveDataBytes, password)
|
||||
|
||||
return &keygenResult{
|
||||
PublicKey: pubKeyBytes,
|
||||
EncryptedShare: encryptedShare,
|
||||
}, nil
|
||||
}
|
||||
|
||||
_ = currentRound
|
||||
}
|
||||
|
||||
func handleOutgoingMessage(msg tss.Message) {
|
||||
msgBytes, _, err := msg.WireBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var toParties []string
|
||||
if !msg.IsBroadcast() {
|
||||
for _, to := range msg.GetTo() {
|
||||
toParties = append(toParties, to.Id)
|
||||
}
|
||||
}
|
||||
|
||||
outMsg := Message{
|
||||
Type: "outgoing",
|
||||
IsBroadcast: msg.IsBroadcast(),
|
||||
ToParties: toParties,
|
||||
Payload: base64.StdEncoding.EncodeToString(msgBytes),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(outMsg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func handleIncomingMessage(
|
||||
msg Message,
|
||||
localParty tss.Party,
|
||||
partyIndexMap map[int]*tss.PartyID,
|
||||
errCh chan error,
|
||||
) {
|
||||
fromParty, ok := partyIndexMap[msg.FromPartyIndex]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := base64.StdEncoding.DecodeString(msg.Payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedMsg, err := tss.ParseWireMessage(payload, fromParty, msg.IsBroadcast)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := localParty.Update(parsedMsg)
|
||||
if err != nil {
|
||||
// Only send fatal errors
|
||||
if !isDuplicateError(err) {
|
||||
errCh <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isDuplicateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return contains(errStr, "duplicate") || contains(errStr, "already received")
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsImpl(s, substr))
|
||||
}
|
||||
|
||||
func containsImpl(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func encryptShare(data []byte, password string) []byte {
|
||||
// TODO: Use proper AES-256-GCM encryption
|
||||
// For now, just prepend a marker and the password hash
|
||||
// This is NOT secure - just a placeholder
|
||||
result := make([]byte, len(data)+32)
|
||||
copy(result[:32], hashPassword(password))
|
||||
copy(result[32:], data)
|
||||
return result
|
||||
}
|
||||
|
||||
func hashPassword(password string) []byte {
|
||||
// Simple hash - should use PBKDF2 or Argon2 in production
|
||||
hash := make([]byte, 32)
|
||||
for i := 0; i < len(password) && i < 32; i++ {
|
||||
hash[i] = password[i]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func sendProgress(round, totalRounds int) {
|
||||
msg := Message{
|
||||
Type: "progress",
|
||||
Round: round,
|
||||
TotalRounds: totalRounds,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func sendError(errMsg string) {
|
||||
msg := Message{
|
||||
Type: "error",
|
||||
Error: errMsg,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
|
||||
msg := Message{
|
||||
Type: "result",
|
||||
PublicKey: base64.StdEncoding.EncodeToString(publicKey),
|
||||
EncryptedShare: base64.StdEncoding.EncodeToString(encryptedShare),
|
||||
PartyIndex: partyIndex,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -37,8 +37,9 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
|
|||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO mpc_sessions (
|
||||
id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
wallet_name, invite_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
message_hash = EXCLUDED.message_hash,
|
||||
|
|
@ -47,7 +48,9 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
|
|||
keygen_session_id = EXCLUDED.keygen_session_id,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
version = EXCLUDED.version
|
||||
version = EXCLUDED.version,
|
||||
wallet_name = EXCLUDED.wallet_name,
|
||||
invite_code = EXCLUDED.invite_code
|
||||
`,
|
||||
session.ID.UUID(),
|
||||
string(session.SessionType),
|
||||
|
|
@ -64,6 +67,8 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
|
|||
session.ExpiresAt,
|
||||
session.CompletedAt,
|
||||
session.Version,
|
||||
session.WalletName,
|
||||
session.InviteCode,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -120,7 +125,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
|||
var session sessionRow
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
|
||||
FROM mpc_sessions WHERE id = $1
|
||||
`, id).Scan(
|
||||
&session.ID,
|
||||
|
|
@ -138,6 +144,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
|||
&session.ExpiresAt,
|
||||
&session.CompletedAt,
|
||||
&session.Version,
|
||||
&session.WalletName,
|
||||
&session.InviteCode,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
|
@ -175,6 +183,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
|||
session.CompletedAt,
|
||||
participants,
|
||||
session.Version,
|
||||
session.WalletName,
|
||||
session.InviteCode,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +192,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
|||
func (r *SessionPostgresRepo) FindByStatus(ctx context.Context, status value_objects.SessionStatus) ([]*entities.MPCSession, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
|
||||
FROM mpc_sessions WHERE status = $1
|
||||
`, status.String())
|
||||
if err != nil {
|
||||
|
|
@ -197,7 +208,8 @@ func (r *SessionPostgresRepo) FindByStatus(ctx context.Context, status value_obj
|
|||
func (r *SessionPostgresRepo) FindExpired(ctx context.Context) ([]*entities.MPCSession, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
|
||||
FROM mpc_sessions
|
||||
WHERE expires_at < NOW() AND status IN ('created', 'in_progress')
|
||||
`)
|
||||
|
|
@ -213,7 +225,8 @@ func (r *SessionPostgresRepo) FindExpired(ctx context.Context) ([]*entities.MPCS
|
|||
func (r *SessionPostgresRepo) FindActive(ctx context.Context) ([]*entities.MPCSession, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
|
||||
FROM mpc_sessions
|
||||
WHERE status IN ('created', 'in_progress')
|
||||
ORDER BY created_at ASC
|
||||
|
|
@ -230,7 +243,8 @@ func (r *SessionPostgresRepo) FindActive(ctx context.Context) ([]*entities.MPCSe
|
|||
func (r *SessionPostgresRepo) FindByCreator(ctx context.Context, creatorID string) ([]*entities.MPCSession, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
|
||||
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
|
||||
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
|
||||
FROM mpc_sessions WHERE created_by = $1
|
||||
ORDER BY created_at DESC
|
||||
`, creatorID)
|
||||
|
|
@ -246,7 +260,8 @@ func (r *SessionPostgresRepo) FindByCreator(ctx context.Context, creatorID strin
|
|||
func (r *SessionPostgresRepo) FindActiveByParticipant(ctx context.Context, partyID value_objects.PartyID) ([]*entities.MPCSession, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT s.id, s.session_type, s.threshold_n, s.threshold_t, s.status,
|
||||
s.message_hash, s.public_key, s.delegate_party_id, s.keygen_session_id, s.created_by, s.created_at, s.updated_at, s.expires_at, s.completed_at, s.version
|
||||
s.message_hash, s.public_key, s.delegate_party_id, s.keygen_session_id, s.created_by, s.created_at, s.updated_at, s.expires_at, s.completed_at, s.version,
|
||||
COALESCE(s.wallet_name, ''), COALESCE(s.invite_code, '')
|
||||
FROM mpc_sessions s
|
||||
JOIN participants p ON s.id = p.session_id
|
||||
WHERE p.party_id = $1 AND s.status IN ('created', 'in_progress')
|
||||
|
|
@ -504,6 +519,8 @@ func (r *SessionPostgresRepo) scanSessions(ctx context.Context, rows *sql.Rows)
|
|||
&s.ExpiresAt,
|
||||
&s.CompletedAt,
|
||||
&s.Version,
|
||||
&s.WalletName,
|
||||
&s.InviteCode,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -537,6 +554,8 @@ func (r *SessionPostgresRepo) scanSessions(ctx context.Context, rows *sql.Rows)
|
|||
s.CompletedAt,
|
||||
participants,
|
||||
s.Version,
|
||||
s.WalletName,
|
||||
s.InviteCode,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -564,6 +583,8 @@ type sessionRow struct {
|
|||
ExpiresAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Version int64
|
||||
WalletName string // 钱包名称 (for co_managed_keygen)
|
||||
InviteCode string // 邀请码 (for co_managed_keygen)
|
||||
}
|
||||
|
||||
type participantRow struct {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ type sessionCacheEntry struct {
|
|||
ExpiresAt int64 `json:"expires_at"`
|
||||
CompletedAt *int64 `json:"completed_at,omitempty"`
|
||||
Participants []participantCacheEntry `json:"participants"`
|
||||
WalletName string `json:"wallet_name,omitempty"` // 钱包名称 (for co_managed_keygen)
|
||||
InviteCode string `json:"invite_code,omitempty"` // 邀请码 (for co_managed_keygen)
|
||||
}
|
||||
|
||||
type participantCacheEntry struct {
|
||||
|
|
@ -214,6 +216,8 @@ func sessionToCacheEntry(s *entities.MPCSession) sessionCacheEntry {
|
|||
ExpiresAt: s.ExpiresAt.UnixMilli(),
|
||||
CompletedAt: completedAt,
|
||||
Participants: participants,
|
||||
WalletName: s.WalletName,
|
||||
InviteCode: s.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,14 +269,17 @@ func cacheEntryToSession(entry sessionCacheEntry) (*entities.MPCSession, error)
|
|||
entry.Status,
|
||||
entry.MessageHash,
|
||||
entry.PublicKey,
|
||||
"", // delegatePartyID - not cached
|
||||
"", // delegatePartyID - not cached
|
||||
uuid.Nil, // keygenSessionID - not cached
|
||||
entry.CreatedBy,
|
||||
time.UnixMilli(entry.CreatedAt),
|
||||
time.UnixMilli(entry.UpdatedAt),
|
||||
time.UnixMilli(entry.ExpiresAt),
|
||||
completedAt,
|
||||
participants,
|
||||
1, // version - default to 1 for cached sessions (not used in cache)
|
||||
1, // version - default to 1 for cached sessions (not used in cache)
|
||||
entry.WalletName, // 钱包名称 (for co_managed_keygen)
|
||||
entry.InviteCode, // 邀请码 (for co_managed_keygen)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,19 @@ var (
|
|||
type SessionType string
|
||||
|
||||
const (
|
||||
SessionTypeKeygen SessionType = "keygen"
|
||||
SessionTypeSign SessionType = "sign"
|
||||
SessionTypeKeygen SessionType = "keygen"
|
||||
SessionTypeSign SessionType = "sign"
|
||||
SessionTypeCoManagedKeygen SessionType = "co_managed_keygen" // 共管钱包密钥生成
|
||||
)
|
||||
|
||||
// IsValid checks if the session type is valid
|
||||
func (t SessionType) IsValid() bool {
|
||||
return t == SessionTypeKeygen || t == SessionTypeSign
|
||||
return t == SessionTypeKeygen || t == SessionTypeSign || t == SessionTypeCoManagedKeygen
|
||||
}
|
||||
|
||||
// IsKeygen checks if the session type is a keygen type (includes co_managed_keygen)
|
||||
func (t SessionType) IsKeygen() bool {
|
||||
return t == SessionTypeKeygen || t == SessionTypeCoManagedKeygen
|
||||
}
|
||||
|
||||
// MPCSession represents an MPC session
|
||||
|
|
@ -52,6 +58,10 @@ type MPCSession struct {
|
|||
ExpiresAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Version int64 // Optimistic locking version number
|
||||
|
||||
// Co-managed wallet specific fields (共管钱包特有字段)
|
||||
WalletName string // 钱包名称 (for co_managed_keygen)
|
||||
InviteCode string // 邀请码 (for co_managed_keygen)
|
||||
}
|
||||
|
||||
// NewMPCSession creates a new MPC session
|
||||
|
|
@ -354,6 +364,8 @@ func (s *MPCSession) ToDTO() SessionDTO {
|
|||
Status: s.Status.String(),
|
||||
CreatedAt: s.CreatedAt,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
WalletName: s.WalletName,
|
||||
InviteCode: s.InviteCode,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,6 +379,8 @@ type SessionDTO struct {
|
|||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
WalletName string `json:"wallet_name,omitempty"` // 钱包名称 (for co_managed_keygen)
|
||||
InviteCode string `json:"invite_code,omitempty"` // 邀请码 (for co_managed_keygen)
|
||||
}
|
||||
|
||||
// ParticipantDTO is a data transfer object for participants
|
||||
|
|
@ -391,6 +405,8 @@ func ReconstructSession(
|
|||
completedAt *time.Time,
|
||||
participants []*Participant,
|
||||
version int64,
|
||||
walletName string, // 钱包名称 (for co_managed_keygen)
|
||||
inviteCode string, // 邀请码 (for co_managed_keygen)
|
||||
) (*MPCSession, error) {
|
||||
sessionStatus, err := value_objects.NewSessionStatus(status)
|
||||
if err != nil {
|
||||
|
|
@ -418,5 +434,7 @@ func ReconstructSession(
|
|||
ExpiresAt: expiresAt,
|
||||
CompletedAt: completedAt,
|
||||
Version: version,
|
||||
WalletName: walletName,
|
||||
InviteCode: inviteCode,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -515,3 +515,60 @@ model SystemConfig {
|
|||
@@index([key])
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Co-Managed Wallet System (共管钱包系统)
|
||||
// =============================================================================
|
||||
|
||||
/// 共管钱包会话状态
|
||||
enum WalletSessionStatus {
|
||||
WAITING // 等待参与方加入
|
||||
READY // 所有参与方已就绪
|
||||
PROCESSING // 密钥生成中
|
||||
COMPLETED // 创建完成
|
||||
FAILED // 创建失败
|
||||
CANCELLED // 已取消
|
||||
}
|
||||
|
||||
/// 共管钱包会话 - 钱包创建过程的会话记录
|
||||
model CoManagedWalletSession {
|
||||
id String @id @default(uuid())
|
||||
walletName String @map("wallet_name") @db.VarChar(100) // 钱包名称
|
||||
thresholdT Int @map("threshold_t") // 签名阈值 T
|
||||
thresholdN Int @map("threshold_n") // 参与方总数 N
|
||||
inviteCode String @unique @map("invite_code") @db.VarChar(20) // 邀请码
|
||||
status WalletSessionStatus @default(WAITING) // 会话状态
|
||||
participants String @db.Text // 参与方列表 (JSON)
|
||||
currentRound Int @default(0) @map("current_round") // 当前密钥生成轮次
|
||||
totalRounds Int @default(3) @map("total_rounds") // 总轮次
|
||||
publicKey String? @map("public_key") @db.VarChar(200) // 生成的公钥
|
||||
error String? @db.Text // 错误信息
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String @map("created_by") @db.VarChar(100) // 创建者
|
||||
|
||||
@@index([inviteCode])
|
||||
@@index([status])
|
||||
@@index([createdBy])
|
||||
@@index([createdAt])
|
||||
@@map("co_managed_wallet_sessions")
|
||||
}
|
||||
|
||||
/// 共管钱包 - 创建成功后的钱包记录
|
||||
model CoManagedWallet {
|
||||
id String @id @default(uuid())
|
||||
sessionId String @unique @map("session_id") // 关联的会话 ID
|
||||
name String @db.VarChar(100) // 钱包名称
|
||||
publicKey String @map("public_key") @db.VarChar(200) // 钱包公钥
|
||||
thresholdT Int @map("threshold_t") // 签名阈值 T
|
||||
thresholdN Int @map("threshold_n") // 参与方总数 N
|
||||
participants String @db.Text // 参与方列表 (JSON)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by") @db.VarChar(100) // 创建者
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([publicKey])
|
||||
@@index([createdBy])
|
||||
@@index([createdAt])
|
||||
@@map("co_managed_wallets")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { CoManagedWalletService } from '../../application/services/co-managed-wallet.service';
|
||||
import {
|
||||
CreateCoManagedWalletSessionDto,
|
||||
JoinSessionDto,
|
||||
ValidateInviteCodeDto,
|
||||
ListSessionsDto,
|
||||
ListWalletsDto,
|
||||
UpdateSessionStatusDto,
|
||||
} from '../dto/request/co-managed-wallet.dto';
|
||||
import {
|
||||
CoManagedWalletSessionDto,
|
||||
CreateSessionResponseDto,
|
||||
ValidateInviteCodeResponseDto,
|
||||
SessionListResponseDto,
|
||||
CoManagedWalletDto,
|
||||
WalletListResponseDto,
|
||||
ThresholdDto,
|
||||
} from '../dto/response/co-managed-wallet.dto';
|
||||
import { WalletSessionStatus } from '../../domain/enums/wallet-session-status.enum';
|
||||
|
||||
@ApiTags('共管钱包')
|
||||
@Controller('admin/co-managed-wallets')
|
||||
export class CoManagedWalletController {
|
||||
constructor(private readonly walletService: CoManagedWalletService) {}
|
||||
|
||||
/**
|
||||
* 创建共管钱包会话
|
||||
*/
|
||||
@Post('sessions')
|
||||
@ApiOperation({ summary: '创建共管钱包会话' })
|
||||
@ApiResponse({ status: 201, type: CreateSessionResponseDto })
|
||||
async createSession(
|
||||
@Body() dto: CreateCoManagedWalletSessionDto,
|
||||
): Promise<CreateSessionResponseDto> {
|
||||
// TODO: 从认证上下文获取用户 ID
|
||||
const createdBy = 'admin-user';
|
||||
|
||||
const session = await this.walletService.createSession({
|
||||
walletName: dto.walletName,
|
||||
thresholdT: dto.thresholdT,
|
||||
thresholdN: dto.thresholdN,
|
||||
initiatorName: dto.initiatorName,
|
||||
createdBy,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session: CoManagedWalletSessionDto.fromEntity(session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邀请码
|
||||
*/
|
||||
@Post('sessions/validate-invite')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '验证邀请码' })
|
||||
@ApiResponse({ status: 200, type: ValidateInviteCodeResponseDto })
|
||||
async validateInviteCode(
|
||||
@Body() dto: ValidateInviteCodeDto,
|
||||
): Promise<ValidateInviteCodeResponseDto> {
|
||||
const result = await this.walletService.validateInviteCode(dto.inviteCode);
|
||||
|
||||
if (!result.valid || !result.session) {
|
||||
return {
|
||||
valid: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
const session = result.session;
|
||||
return {
|
||||
valid: true,
|
||||
session: {
|
||||
sessionId: session.id,
|
||||
walletName: session.walletName,
|
||||
threshold: ThresholdDto.fromThreshold(session.threshold),
|
||||
initiator: session.participants[0]?.name || 'Unknown',
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
currentParticipants: session.participants.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话
|
||||
*/
|
||||
@Post('sessions/:sessionId/join')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '加入会话' })
|
||||
@ApiParam({ name: 'sessionId', description: '会话 ID' })
|
||||
@ApiResponse({ status: 200, type: CoManagedWalletSessionDto })
|
||||
async joinSession(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() dto: JoinSessionDto,
|
||||
): Promise<CoManagedWalletSessionDto> {
|
||||
const session = await this.walletService.getSession(sessionId);
|
||||
const updatedSession = await this.walletService.joinSession(
|
||||
session.inviteCode,
|
||||
dto.participantName,
|
||||
dto.partyId,
|
||||
);
|
||||
return CoManagedWalletSessionDto.fromEntity(updatedSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*/
|
||||
@Get('sessions/:sessionId')
|
||||
@ApiOperation({ summary: '获取会话详情' })
|
||||
@ApiParam({ name: 'sessionId', description: '会话 ID' })
|
||||
@ApiResponse({ status: 200, type: CoManagedWalletSessionDto })
|
||||
async getSession(
|
||||
@Param('sessionId') sessionId: string,
|
||||
): Promise<CoManagedWalletSessionDto> {
|
||||
const session = await this.walletService.getSession(sessionId);
|
||||
return CoManagedWalletSessionDto.fromEntity(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
@Get('sessions')
|
||||
@ApiOperation({ summary: '获取会话列表' })
|
||||
@ApiResponse({ status: 200, type: SessionListResponseDto })
|
||||
async listSessions(@Query() dto: ListSessionsDto): Promise<SessionListResponseDto> {
|
||||
const result = await this.walletService.listSessions({
|
||||
page: dto.page ?? 1,
|
||||
pageSize: dto.pageSize ?? 10,
|
||||
status: dto.status,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((s) => CoManagedWalletSessionDto.fromEntity(s)),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始密钥生成
|
||||
*/
|
||||
@Post('sessions/:sessionId/start-keygen')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '开始密钥生成' })
|
||||
@ApiParam({ name: 'sessionId', description: '会话 ID' })
|
||||
@ApiResponse({ status: 200, type: CoManagedWalletSessionDto })
|
||||
async startKeygen(
|
||||
@Param('sessionId') sessionId: string,
|
||||
): Promise<CoManagedWalletSessionDto> {
|
||||
const session = await this.walletService.startKeygen(sessionId);
|
||||
return CoManagedWalletSessionDto.fromEntity(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话状态 (内部回调使用)
|
||||
*/
|
||||
@Put('sessions/:sessionId/status')
|
||||
@ApiOperation({ summary: '更新会话状态' })
|
||||
@ApiParam({ name: 'sessionId', description: '会话 ID' })
|
||||
@ApiResponse({ status: 200, type: CoManagedWalletSessionDto })
|
||||
async updateSessionStatus(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() dto: UpdateSessionStatusDto,
|
||||
): Promise<CoManagedWalletSessionDto> {
|
||||
let session;
|
||||
|
||||
switch (dto.status) {
|
||||
case WalletSessionStatus.PROCESSING:
|
||||
if (dto.currentRound !== undefined) {
|
||||
session = await this.walletService.updateSessionRound(sessionId, dto.currentRound);
|
||||
} else {
|
||||
session = await this.walletService.startKeygen(sessionId);
|
||||
}
|
||||
break;
|
||||
|
||||
case WalletSessionStatus.COMPLETED:
|
||||
if (!dto.publicKey) {
|
||||
throw new Error('完成状态需要提供公钥');
|
||||
}
|
||||
const result = await this.walletService.completeSession(sessionId, dto.publicKey);
|
||||
session = result.session;
|
||||
break;
|
||||
|
||||
case WalletSessionStatus.FAILED:
|
||||
session = await this.walletService.failSession(sessionId, dto.error || '未知错误');
|
||||
break;
|
||||
|
||||
case WalletSessionStatus.CANCELLED:
|
||||
await this.walletService.cancelSession(sessionId);
|
||||
session = await this.walletService.getSession(sessionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('不支持的状态更新');
|
||||
}
|
||||
|
||||
return CoManagedWalletSessionDto.fromEntity(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消会话
|
||||
*/
|
||||
@Delete('sessions/:sessionId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '取消会话' })
|
||||
@ApiParam({ name: 'sessionId', description: '会话 ID' })
|
||||
@ApiResponse({ status: 204 })
|
||||
async cancelSession(@Param('sessionId') sessionId: string): Promise<void> {
|
||||
await this.walletService.cancelSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钱包列表
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取共管钱包列表' })
|
||||
@ApiResponse({ status: 200, type: WalletListResponseDto })
|
||||
async listWallets(@Query() dto: ListWalletsDto): Promise<WalletListResponseDto> {
|
||||
const result = await this.walletService.listWallets({
|
||||
page: dto.page ?? 1,
|
||||
pageSize: dto.pageSize ?? 10,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((w) => CoManagedWalletDto.fromEntity(w)),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
totalPages: result.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钱包详情
|
||||
*/
|
||||
@Get(':walletId')
|
||||
@ApiOperation({ summary: '获取钱包详情' })
|
||||
@ApiParam({ name: 'walletId', description: '钱包 ID' })
|
||||
@ApiResponse({ status: 200, type: CoManagedWalletDto })
|
||||
async getWallet(@Param('walletId') walletId: string): Promise<CoManagedWalletDto> {
|
||||
const wallet = await this.walletService.getWallet(walletId);
|
||||
return CoManagedWalletDto.fromEntity(wallet);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { IsString, IsNumber, Min, Max, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { WalletSessionStatus } from '../../../domain/enums/wallet-session-status.enum';
|
||||
|
||||
/**
|
||||
* 创建共管钱包会话 DTO
|
||||
*/
|
||||
export class CreateCoManagedWalletSessionDto {
|
||||
@ApiProperty({ description: '钱包名称' })
|
||||
@IsString()
|
||||
walletName: string;
|
||||
|
||||
@ApiProperty({ description: '签名阈值 (T)', minimum: 1, maximum: 10 })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
thresholdT: number;
|
||||
|
||||
@ApiProperty({ description: '参与方总数 (N)', minimum: 2, maximum: 10 })
|
||||
@IsNumber()
|
||||
@Min(2)
|
||||
@Max(10)
|
||||
thresholdN: number;
|
||||
|
||||
@ApiProperty({ description: '发起者名称' })
|
||||
@IsString()
|
||||
initiatorName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话 DTO
|
||||
*/
|
||||
export class JoinSessionDto {
|
||||
@ApiProperty({ description: '参与者名称' })
|
||||
@IsString()
|
||||
participantName: string;
|
||||
|
||||
@ApiProperty({ description: '参与方 ID' })
|
||||
@IsString()
|
||||
partyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邀请码 DTO
|
||||
*/
|
||||
export class ValidateInviteCodeDto {
|
||||
@ApiProperty({ description: '邀请码' })
|
||||
@IsString()
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会话列表 DTO
|
||||
*/
|
||||
export class ListSessionsDto {
|
||||
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ description: '会话状态', enum: WalletSessionStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(WalletSessionStatus)
|
||||
status?: WalletSessionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询钱包列表 DTO
|
||||
*/
|
||||
export class ListWalletsDto {
|
||||
@ApiPropertyOptional({ description: '页码', default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ description: '每页数量', default: 10 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number = 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话状态 DTO (内部使用)
|
||||
*/
|
||||
export class UpdateSessionStatusDto {
|
||||
@ApiProperty({ description: '会话状态', enum: WalletSessionStatus })
|
||||
@IsEnum(WalletSessionStatus)
|
||||
status: WalletSessionStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: '当前轮次' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
currentRound?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '公钥' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
publicKey?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
CoManagedWalletSessionEntity,
|
||||
CoManagedWalletEntity,
|
||||
Participant,
|
||||
ThresholdConfig,
|
||||
} from '../../../domain/entities/co-managed-wallet.entity';
|
||||
import {
|
||||
WalletSessionStatus,
|
||||
ParticipantStatus,
|
||||
} from '../../../domain/enums/wallet-session-status.enum';
|
||||
|
||||
/**
|
||||
* 参与方响应 DTO
|
||||
*/
|
||||
export class ParticipantDto {
|
||||
@ApiProperty({ description: '参与方 ID' })
|
||||
partyId: string;
|
||||
|
||||
@ApiProperty({ description: '参与方名称' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: ParticipantStatus })
|
||||
status: ParticipantStatus;
|
||||
|
||||
@ApiProperty({ description: '加入时间' })
|
||||
joinedAt: string;
|
||||
|
||||
static fromParticipant(participant: Participant): ParticipantDto {
|
||||
const dto = new ParticipantDto();
|
||||
dto.partyId = participant.partyId;
|
||||
dto.name = participant.name;
|
||||
dto.status = participant.status;
|
||||
dto.joinedAt = participant.joinedAt.toISOString();
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值配置响应 DTO
|
||||
*/
|
||||
export class ThresholdDto {
|
||||
@ApiProperty({ description: '签名阈值 (T)' })
|
||||
t: number;
|
||||
|
||||
@ApiProperty({ description: '参与方总数 (N)' })
|
||||
n: number;
|
||||
|
||||
static fromThreshold(threshold: ThresholdConfig): ThresholdDto {
|
||||
const dto = new ThresholdDto();
|
||||
dto.t = threshold.t;
|
||||
dto.n = threshold.n;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 共管钱包会话响应 DTO
|
||||
*/
|
||||
export class CoManagedWalletSessionDto {
|
||||
@ApiProperty({ description: '会话 ID' })
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({ description: '钱包名称' })
|
||||
walletName: string;
|
||||
|
||||
@ApiProperty({ description: '阈值配置' })
|
||||
threshold: ThresholdDto;
|
||||
|
||||
@ApiProperty({ description: '邀请码' })
|
||||
inviteCode: string;
|
||||
|
||||
@ApiProperty({ description: '邀请链接' })
|
||||
inviteUrl: string;
|
||||
|
||||
@ApiProperty({ description: '状态', enum: WalletSessionStatus })
|
||||
status: WalletSessionStatus;
|
||||
|
||||
@ApiProperty({ description: '参与方列表', type: [ParticipantDto] })
|
||||
participants: ParticipantDto[];
|
||||
|
||||
@ApiProperty({ description: '当前轮次' })
|
||||
currentRound: number;
|
||||
|
||||
@ApiProperty({ description: '总轮次' })
|
||||
totalRounds: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '公钥' })
|
||||
publicKey?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
error?: string;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ description: '更新时间' })
|
||||
updatedAt: string;
|
||||
|
||||
@ApiProperty({ description: '创建者' })
|
||||
createdBy: string;
|
||||
|
||||
static fromEntity(
|
||||
entity: CoManagedWalletSessionEntity,
|
||||
baseUrl: string = 'https://app.rwadurian.com',
|
||||
): CoManagedWalletSessionDto {
|
||||
const dto = new CoManagedWalletSessionDto();
|
||||
dto.sessionId = entity.id;
|
||||
dto.walletName = entity.walletName;
|
||||
dto.threshold = ThresholdDto.fromThreshold(entity.threshold);
|
||||
dto.inviteCode = entity.inviteCode;
|
||||
dto.inviteUrl = `${baseUrl}/join/${entity.inviteCode}`;
|
||||
dto.status = entity.status;
|
||||
dto.participants = entity.participants.map(ParticipantDto.fromParticipant);
|
||||
dto.currentRound = entity.currentRound;
|
||||
dto.totalRounds = entity.totalRounds;
|
||||
dto.publicKey = entity.publicKey ?? undefined;
|
||||
dto.error = entity.error ?? undefined;
|
||||
dto.createdAt = entity.createdAt.toISOString();
|
||||
dto.updatedAt = entity.updatedAt.toISOString();
|
||||
dto.createdBy = entity.createdBy;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会话响应 DTO
|
||||
*/
|
||||
export class CreateSessionResponseDto {
|
||||
@ApiProperty({ description: '是否成功' })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '会话信息' })
|
||||
session: CoManagedWalletSessionDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邀请码响应 DTO
|
||||
*/
|
||||
export class ValidateInviteCodeResponseDto {
|
||||
@ApiProperty({ description: '是否有效' })
|
||||
valid: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '会话信息' })
|
||||
session?: {
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
threshold: ThresholdDto;
|
||||
initiator: string;
|
||||
createdAt: string;
|
||||
currentParticipants: number;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息' })
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话列表响应 DTO
|
||||
*/
|
||||
export class SessionListResponseDto {
|
||||
@ApiProperty({ description: '会话列表', type: [CoManagedWalletSessionDto] })
|
||||
items: CoManagedWalletSessionDto[];
|
||||
|
||||
@ApiProperty({ description: '总数' })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ description: '当前页' })
|
||||
page: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量' })
|
||||
pageSize: number;
|
||||
|
||||
@ApiProperty({ description: '总页数' })
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 共管钱包响应 DTO
|
||||
*/
|
||||
export class CoManagedWalletDto {
|
||||
@ApiProperty({ description: '钱包 ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: '会话 ID' })
|
||||
sessionId: string;
|
||||
|
||||
@ApiProperty({ description: '钱包名称' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: '公钥' })
|
||||
publicKey: string;
|
||||
|
||||
@ApiProperty({ description: '阈值配置' })
|
||||
threshold: ThresholdDto;
|
||||
|
||||
@ApiProperty({ description: '参与方数量' })
|
||||
participantCount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '参与方列表',
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { partyId: { type: 'string' }, name: { type: 'string' } } },
|
||||
})
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ description: '创建者' })
|
||||
createdBy: string;
|
||||
|
||||
static fromEntity(entity: CoManagedWalletEntity): CoManagedWalletDto {
|
||||
const dto = new CoManagedWalletDto();
|
||||
dto.id = entity.id;
|
||||
dto.sessionId = entity.sessionId;
|
||||
dto.name = entity.name;
|
||||
dto.publicKey = entity.publicKey;
|
||||
dto.threshold = ThresholdDto.fromThreshold(entity.threshold);
|
||||
dto.participantCount = entity.participants.length;
|
||||
dto.participants = entity.participants;
|
||||
dto.createdAt = entity.createdAt.toISOString();
|
||||
dto.createdBy = entity.createdBy;
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 钱包列表响应 DTO
|
||||
*/
|
||||
export class WalletListResponseDto {
|
||||
@ApiProperty({ description: '钱包列表', type: [CoManagedWalletDto] })
|
||||
items: CoManagedWalletDto[];
|
||||
|
||||
@ApiProperty({ description: '总数' })
|
||||
total: number;
|
||||
|
||||
@ApiProperty({ description: '当前页' })
|
||||
page: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量' })
|
||||
pageSize: number;
|
||||
|
||||
@ApiProperty({ description: '总页数' })
|
||||
totalPages: number;
|
||||
}
|
||||
|
|
@ -50,6 +50,16 @@ import { UserTagController } from './api/controllers/user-tag.controller';
|
|||
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||
// Co-Managed Wallet imports
|
||||
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
|
||||
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
|
||||
import { CoManagedWalletMapper } from './infrastructure/persistence/mappers/co-managed-wallet.mapper';
|
||||
import { CoManagedWalletSessionRepositoryImpl } from './infrastructure/persistence/repositories/co-managed-wallet-session.repository.impl';
|
||||
import { CoManagedWalletRepositoryImpl } from './infrastructure/persistence/repositories/co-managed-wallet.repository.impl';
|
||||
import {
|
||||
CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
||||
CO_MANAGED_WALLET_REPOSITORY,
|
||||
} from './domain/repositories/co-managed-wallet.repository';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -79,6 +89,8 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
|||
UserTagController,
|
||||
ClassificationRuleController,
|
||||
AudienceSegmentController,
|
||||
// Co-Managed Wallet Controller
|
||||
CoManagedWalletController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -134,6 +146,17 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
|||
AudienceSegmentService,
|
||||
// Scheduled Jobs
|
||||
AutoTagSyncJob,
|
||||
// Co-Managed Wallet
|
||||
CoManagedWalletMapper,
|
||||
CoManagedWalletService,
|
||||
{
|
||||
provide: CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
||||
useClass: CoManagedWalletSessionRepositoryImpl,
|
||||
},
|
||||
{
|
||||
provide: CO_MANAGED_WALLET_REPOSITORY,
|
||||
useClass: CoManagedWalletRepositoryImpl,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
CoManagedWalletSessionEntity,
|
||||
CoManagedWalletEntity,
|
||||
} from '../../domain/entities/co-managed-wallet.entity';
|
||||
import {
|
||||
CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
||||
CO_MANAGED_WALLET_REPOSITORY,
|
||||
CoManagedWalletSessionRepository,
|
||||
CoManagedWalletRepository,
|
||||
} from '../../domain/repositories/co-managed-wallet.repository';
|
||||
import { WalletSessionStatus } from '../../domain/enums/wallet-session-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class CoManagedWalletService {
|
||||
constructor(
|
||||
@Inject(CO_MANAGED_WALLET_SESSION_REPOSITORY)
|
||||
private readonly sessionRepository: CoManagedWalletSessionRepository,
|
||||
@Inject(CO_MANAGED_WALLET_REPOSITORY)
|
||||
private readonly walletRepository: CoManagedWalletRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 生成邀请码
|
||||
*/
|
||||
private generateInviteCode(): string {
|
||||
const bytes = randomBytes(6);
|
||||
const code = bytes.toString('hex').toUpperCase();
|
||||
// 格式化为 XXXX-XXXX-XXXX
|
||||
return `${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建共管钱包会话
|
||||
*/
|
||||
async createSession(params: {
|
||||
walletName: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
initiatorName: string;
|
||||
createdBy: string;
|
||||
}): Promise<CoManagedWalletSessionEntity> {
|
||||
// 验证阈值
|
||||
if (params.thresholdT > params.thresholdN) {
|
||||
throw new BadRequestException('签名阈值不能大于参与方总数');
|
||||
}
|
||||
if (params.thresholdT < 1) {
|
||||
throw new BadRequestException('签名阈值至少为 1');
|
||||
}
|
||||
if (params.thresholdN < 2) {
|
||||
throw new BadRequestException('参与方总数至少为 2');
|
||||
}
|
||||
|
||||
const session = CoManagedWalletSessionEntity.create({
|
||||
id: uuidv4(),
|
||||
walletName: params.walletName,
|
||||
threshold: { t: params.thresholdT, n: params.thresholdN },
|
||||
inviteCode: this.generateInviteCode(),
|
||||
createdBy: params.createdBy,
|
||||
initiatorName: params.initiatorName,
|
||||
initiatorPartyId: uuidv4(), // 为发起者生成 partyId
|
||||
});
|
||||
|
||||
return this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<CoManagedWalletSessionEntity> {
|
||||
const session = await this.sessionRepository.findById(sessionId);
|
||||
if (!session) {
|
||||
throw new NotFoundException('会话不存在');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邀请码
|
||||
*/
|
||||
async validateInviteCode(inviteCode: string): Promise<{
|
||||
valid: boolean;
|
||||
session?: CoManagedWalletSessionEntity;
|
||||
error?: string;
|
||||
}> {
|
||||
const session = await this.sessionRepository.findByInviteCode(inviteCode);
|
||||
|
||||
if (!session) {
|
||||
return { valid: false, error: '无效的邀请码' };
|
||||
}
|
||||
|
||||
if (session.status !== WalletSessionStatus.WAITING) {
|
||||
return { valid: false, error: '会话已不在等待状态' };
|
||||
}
|
||||
|
||||
if (!session.canAddParticipant()) {
|
||||
return { valid: false, error: '会话参与方已满' };
|
||||
}
|
||||
|
||||
return { valid: true, session };
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话
|
||||
*/
|
||||
async joinSession(
|
||||
inviteCode: string,
|
||||
participantName: string,
|
||||
partyId: string,
|
||||
): Promise<CoManagedWalletSessionEntity> {
|
||||
const { valid, session, error } = await this.validateInviteCode(inviteCode);
|
||||
|
||||
if (!valid || !session) {
|
||||
throw new BadRequestException(error || '无法加入会话');
|
||||
}
|
||||
|
||||
session.addParticipant(partyId, participantName);
|
||||
return this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始密钥生成
|
||||
*/
|
||||
async startKeygen(sessionId: string): Promise<CoManagedWalletSessionEntity> {
|
||||
const session = await this.getSession(sessionId);
|
||||
|
||||
if (session.status !== WalletSessionStatus.READY) {
|
||||
throw new BadRequestException('会话未就绪,需要所有参与方加入后才能开始');
|
||||
}
|
||||
|
||||
session.startKeygen();
|
||||
return this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话轮次
|
||||
*/
|
||||
async updateSessionRound(
|
||||
sessionId: string,
|
||||
round: number,
|
||||
): Promise<CoManagedWalletSessionEntity> {
|
||||
const session = await this.getSession(sessionId);
|
||||
session.updateRound(round);
|
||||
return this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成会话并创建钱包
|
||||
*/
|
||||
async completeSession(
|
||||
sessionId: string,
|
||||
publicKey: string,
|
||||
): Promise<{ session: CoManagedWalletSessionEntity; wallet: CoManagedWalletEntity }> {
|
||||
const session = await this.getSession(sessionId);
|
||||
session.complete(publicKey);
|
||||
const savedSession = await this.sessionRepository.save(session);
|
||||
|
||||
// 创建钱包记录
|
||||
const wallet = CoManagedWalletEntity.create({
|
||||
id: uuidv4(),
|
||||
sessionId: session.id,
|
||||
name: session.walletName,
|
||||
publicKey,
|
||||
threshold: session.threshold,
|
||||
participants: session.participants.map((p) => ({
|
||||
partyId: p.partyId,
|
||||
name: p.name,
|
||||
})),
|
||||
createdBy: session.createdBy,
|
||||
});
|
||||
|
||||
const savedWallet = await this.walletRepository.save(wallet);
|
||||
|
||||
return { session: savedSession, wallet: savedWallet };
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记会话失败
|
||||
*/
|
||||
async failSession(
|
||||
sessionId: string,
|
||||
error: string,
|
||||
): Promise<CoManagedWalletSessionEntity> {
|
||||
const session = await this.getSession(sessionId);
|
||||
session.fail(error);
|
||||
return this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消会话
|
||||
*/
|
||||
async cancelSession(sessionId: string): Promise<void> {
|
||||
const session = await this.getSession(sessionId);
|
||||
session.cancel();
|
||||
await this.sessionRepository.save(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
async listSessions(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: WalletSessionStatus;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletSessionEntity[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const { items, total } = await this.sessionRepository.findAll(params);
|
||||
const totalPages = Math.ceil(total / params.pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钱包详情
|
||||
*/
|
||||
async getWallet(walletId: string): Promise<CoManagedWalletEntity> {
|
||||
const wallet = await this.walletRepository.findById(walletId);
|
||||
if (!wallet) {
|
||||
throw new NotFoundException('钱包不存在');
|
||||
}
|
||||
return wallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取钱包列表
|
||||
*/
|
||||
async listWallets(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletEntity[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const { items, total } = await this.walletRepository.findAll(params);
|
||||
const totalPages = Math.ceil(total / params.pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
import { WalletSessionStatus, ParticipantStatus } from '../enums/wallet-session-status.enum';
|
||||
|
||||
/**
|
||||
* 参与方信息
|
||||
*/
|
||||
export interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: ParticipantStatus;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值配置
|
||||
*/
|
||||
export interface ThresholdConfig {
|
||||
t: number; // 签名所需最少参与方数量
|
||||
n: number; // 总参与方数量
|
||||
}
|
||||
|
||||
/**
|
||||
* 共管钱包会话实体
|
||||
*/
|
||||
export class CoManagedWalletSessionEntity {
|
||||
private constructor(
|
||||
private readonly _id: string,
|
||||
private readonly _walletName: string,
|
||||
private readonly _threshold: ThresholdConfig,
|
||||
private readonly _inviteCode: string,
|
||||
private _status: WalletSessionStatus,
|
||||
private _participants: Participant[],
|
||||
private _currentRound: number,
|
||||
private _totalRounds: number,
|
||||
private _publicKey: string | null,
|
||||
private _error: string | null,
|
||||
private readonly _createdAt: Date,
|
||||
private _updatedAt: Date,
|
||||
private readonly _createdBy: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
static create(params: {
|
||||
id: string;
|
||||
walletName: string;
|
||||
threshold: ThresholdConfig;
|
||||
inviteCode: string;
|
||||
createdBy: string;
|
||||
initiatorName: string;
|
||||
initiatorPartyId: string;
|
||||
}): CoManagedWalletSessionEntity {
|
||||
const now = new Date();
|
||||
const initialParticipant: Participant = {
|
||||
partyId: params.initiatorPartyId,
|
||||
name: params.initiatorName,
|
||||
status: ParticipantStatus.READY,
|
||||
joinedAt: now,
|
||||
};
|
||||
|
||||
return new CoManagedWalletSessionEntity(
|
||||
params.id,
|
||||
params.walletName,
|
||||
params.threshold,
|
||||
params.inviteCode,
|
||||
WalletSessionStatus.WAITING,
|
||||
[initialParticipant],
|
||||
0,
|
||||
3, // 默认 3 轮
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
now,
|
||||
params.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从持久化数据重建实体
|
||||
*/
|
||||
static reconstitute(params: {
|
||||
id: string;
|
||||
walletName: string;
|
||||
threshold: ThresholdConfig;
|
||||
inviteCode: string;
|
||||
status: WalletSessionStatus;
|
||||
participants: Participant[];
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
publicKey: string | null;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string;
|
||||
}): CoManagedWalletSessionEntity {
|
||||
return new CoManagedWalletSessionEntity(
|
||||
params.id,
|
||||
params.walletName,
|
||||
params.threshold,
|
||||
params.inviteCode,
|
||||
params.status,
|
||||
params.participants,
|
||||
params.currentRound,
|
||||
params.totalRounds,
|
||||
params.publicKey,
|
||||
params.error,
|
||||
params.createdAt,
|
||||
params.updatedAt,
|
||||
params.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get walletName(): string {
|
||||
return this._walletName;
|
||||
}
|
||||
|
||||
get threshold(): ThresholdConfig {
|
||||
return { ...this._threshold };
|
||||
}
|
||||
|
||||
get inviteCode(): string {
|
||||
return this._inviteCode;
|
||||
}
|
||||
|
||||
get status(): WalletSessionStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get participants(): Participant[] {
|
||||
return [...this._participants];
|
||||
}
|
||||
|
||||
get currentRound(): number {
|
||||
return this._currentRound;
|
||||
}
|
||||
|
||||
get totalRounds(): number {
|
||||
return this._totalRounds;
|
||||
}
|
||||
|
||||
get publicKey(): string | null {
|
||||
return this._publicKey;
|
||||
}
|
||||
|
||||
get error(): string | null {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
|
||||
get createdBy(): string {
|
||||
return this._createdBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以添加新参与方
|
||||
*/
|
||||
canAddParticipant(): boolean {
|
||||
return (
|
||||
this._status === WalletSessionStatus.WAITING &&
|
||||
this._participants.length < this._threshold.n
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加参与方
|
||||
*/
|
||||
addParticipant(partyId: string, name: string): void {
|
||||
if (!this.canAddParticipant()) {
|
||||
throw new Error('无法添加参与方');
|
||||
}
|
||||
|
||||
if (this._participants.some((p) => p.partyId === partyId)) {
|
||||
throw new Error('参与方已存在');
|
||||
}
|
||||
|
||||
this._participants.push({
|
||||
partyId,
|
||||
name,
|
||||
status: ParticipantStatus.READY,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
this._updatedAt = new Date();
|
||||
|
||||
// 检查是否所有参与方都已加入
|
||||
if (this._participants.length === this._threshold.n) {
|
||||
this._status = WalletSessionStatus.READY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始密钥生成
|
||||
*/
|
||||
startKeygen(): void {
|
||||
if (this._status !== WalletSessionStatus.READY) {
|
||||
throw new Error('会话未就绪,无法开始密钥生成');
|
||||
}
|
||||
|
||||
this._status = WalletSessionStatus.PROCESSING;
|
||||
this._currentRound = 1;
|
||||
this._participants = this._participants.map((p) => ({
|
||||
...p,
|
||||
status: ParticipantStatus.PROCESSING,
|
||||
}));
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新轮次
|
||||
*/
|
||||
updateRound(round: number): void {
|
||||
if (this._status !== WalletSessionStatus.PROCESSING) {
|
||||
throw new Error('会话不在处理中状态');
|
||||
}
|
||||
|
||||
this._currentRound = round;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成密钥生成
|
||||
*/
|
||||
complete(publicKey: string): void {
|
||||
if (this._status !== WalletSessionStatus.PROCESSING) {
|
||||
throw new Error('会话不在处理中状态');
|
||||
}
|
||||
|
||||
this._status = WalletSessionStatus.COMPLETED;
|
||||
this._publicKey = publicKey;
|
||||
this._participants = this._participants.map((p) => ({
|
||||
...p,
|
||||
status: ParticipantStatus.COMPLETED,
|
||||
}));
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记失败
|
||||
*/
|
||||
fail(error: string): void {
|
||||
this._status = WalletSessionStatus.FAILED;
|
||||
this._error = error;
|
||||
this._participants = this._participants.map((p) => ({
|
||||
...p,
|
||||
status: ParticipantStatus.FAILED,
|
||||
}));
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消会话
|
||||
*/
|
||||
cancel(): void {
|
||||
if (
|
||||
this._status === WalletSessionStatus.COMPLETED ||
|
||||
this._status === WalletSessionStatus.FAILED
|
||||
) {
|
||||
throw new Error('无法取消已完成或已失败的会话');
|
||||
}
|
||||
|
||||
this._status = WalletSessionStatus.CANCELLED;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 共管钱包实体 (创建成功后的记录)
|
||||
*/
|
||||
export class CoManagedWalletEntity {
|
||||
private constructor(
|
||||
private readonly _id: string,
|
||||
private readonly _sessionId: string,
|
||||
private readonly _name: string,
|
||||
private readonly _publicKey: string,
|
||||
private readonly _threshold: ThresholdConfig,
|
||||
private readonly _participants: Array<{ partyId: string; name: string }>,
|
||||
private readonly _createdAt: Date,
|
||||
private readonly _createdBy: string,
|
||||
) {}
|
||||
|
||||
static create(params: {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
threshold: ThresholdConfig;
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
createdBy: string;
|
||||
}): CoManagedWalletEntity {
|
||||
return new CoManagedWalletEntity(
|
||||
params.id,
|
||||
params.sessionId,
|
||||
params.name,
|
||||
params.publicKey,
|
||||
params.threshold,
|
||||
params.participants,
|
||||
new Date(),
|
||||
params.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(params: {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
threshold: ThresholdConfig;
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
}): CoManagedWalletEntity {
|
||||
return new CoManagedWalletEntity(
|
||||
params.id,
|
||||
params.sessionId,
|
||||
params.name,
|
||||
params.publicKey,
|
||||
params.threshold,
|
||||
params.participants,
|
||||
params.createdAt,
|
||||
params.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get publicKey(): string {
|
||||
return this._publicKey;
|
||||
}
|
||||
|
||||
get threshold(): ThresholdConfig {
|
||||
return { ...this._threshold };
|
||||
}
|
||||
|
||||
get participants(): Array<{ partyId: string; name: string }> {
|
||||
return [...this._participants];
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get createdBy(): string {
|
||||
return this._createdBy;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* 共管钱包会话状态枚举
|
||||
*/
|
||||
export enum WalletSessionStatus {
|
||||
/** 等待参与方加入 */
|
||||
WAITING = 'WAITING',
|
||||
/** 所有参与方已就绪 */
|
||||
READY = 'READY',
|
||||
/** 密钥生成中 */
|
||||
PROCESSING = 'PROCESSING',
|
||||
/** 创建完成 */
|
||||
COMPLETED = 'COMPLETED',
|
||||
/** 创建失败 */
|
||||
FAILED = 'FAILED',
|
||||
/** 已取消 */
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与方状态枚举
|
||||
*/
|
||||
export enum ParticipantStatus {
|
||||
/** 等待中 */
|
||||
WAITING = 'WAITING',
|
||||
/** 已就绪 */
|
||||
READY = 'READY',
|
||||
/** 处理中 */
|
||||
PROCESSING = 'PROCESSING',
|
||||
/** 已完成 */
|
||||
COMPLETED = 'COMPLETED',
|
||||
/** 失败 */
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
CoManagedWalletSessionEntity,
|
||||
CoManagedWalletEntity,
|
||||
} from '../entities/co-managed-wallet.entity';
|
||||
import { WalletSessionStatus } from '../enums/wallet-session-status.enum';
|
||||
|
||||
export const CO_MANAGED_WALLET_SESSION_REPOSITORY = Symbol(
|
||||
'CO_MANAGED_WALLET_SESSION_REPOSITORY',
|
||||
);
|
||||
|
||||
export const CO_MANAGED_WALLET_REPOSITORY = Symbol('CO_MANAGED_WALLET_REPOSITORY');
|
||||
|
||||
/**
|
||||
* 共管钱包会话仓储接口
|
||||
*/
|
||||
export interface CoManagedWalletSessionRepository {
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
save(entity: CoManagedWalletSessionEntity): Promise<CoManagedWalletSessionEntity>;
|
||||
|
||||
/**
|
||||
* 根据 ID 查找会话
|
||||
*/
|
||||
findById(id: string): Promise<CoManagedWalletSessionEntity | null>;
|
||||
|
||||
/**
|
||||
* 根据邀请码查找会话
|
||||
*/
|
||||
findByInviteCode(inviteCode: string): Promise<CoManagedWalletSessionEntity | null>;
|
||||
|
||||
/**
|
||||
* 查找用户创建的会话列表
|
||||
*/
|
||||
findByCreatedBy(
|
||||
createdBy: string,
|
||||
status?: WalletSessionStatus,
|
||||
): Promise<CoManagedWalletSessionEntity[]>;
|
||||
|
||||
/**
|
||||
* 查找所有会话 (分页)
|
||||
*/
|
||||
findAll(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: WalletSessionStatus;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletSessionEntity[];
|
||||
total: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 共管钱包仓储接口
|
||||
*/
|
||||
export interface CoManagedWalletRepository {
|
||||
/**
|
||||
* 保存钱包
|
||||
*/
|
||||
save(entity: CoManagedWalletEntity): Promise<CoManagedWalletEntity>;
|
||||
|
||||
/**
|
||||
* 根据 ID 查找钱包
|
||||
*/
|
||||
findById(id: string): Promise<CoManagedWalletEntity | null>;
|
||||
|
||||
/**
|
||||
* 根据会话 ID 查找钱包
|
||||
*/
|
||||
findBySessionId(sessionId: string): Promise<CoManagedWalletEntity | null>;
|
||||
|
||||
/**
|
||||
* 查找用户创建的钱包列表
|
||||
*/
|
||||
findByCreatedBy(createdBy: string): Promise<CoManagedWalletEntity[]>;
|
||||
|
||||
/**
|
||||
* 查找所有钱包 (分页)
|
||||
*/
|
||||
findAll(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletEntity[];
|
||||
total: number;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 删除钱包
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
CoManagedWalletSessionEntity,
|
||||
CoManagedWalletEntity,
|
||||
Participant,
|
||||
ThresholdConfig,
|
||||
} from '../../../domain/entities/co-managed-wallet.entity';
|
||||
import {
|
||||
WalletSessionStatus,
|
||||
ParticipantStatus,
|
||||
} from '../../../domain/enums/wallet-session-status.enum';
|
||||
|
||||
/**
|
||||
* Prisma 会话模型类型 (需要在 schema.prisma 中定义)
|
||||
*/
|
||||
export interface PrismaCoManagedWalletSession {
|
||||
id: string;
|
||||
walletName: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
inviteCode: string;
|
||||
status: string;
|
||||
participants: string; // JSON string
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
publicKey: string | null;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma 钱包模型类型
|
||||
*/
|
||||
export interface PrismaCoManagedWallet {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
participants: string; // JSON string
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CoManagedWalletMapper {
|
||||
/**
|
||||
* 将 Prisma 会话模型转换为领域实体
|
||||
*/
|
||||
sessionToDomain(prisma: PrismaCoManagedWalletSession): CoManagedWalletSessionEntity {
|
||||
const participants: Participant[] = JSON.parse(prisma.participants).map(
|
||||
(p: { partyId: string; name: string; status: string; joinedAt: string }) => ({
|
||||
partyId: p.partyId,
|
||||
name: p.name,
|
||||
status: p.status as ParticipantStatus,
|
||||
joinedAt: new Date(p.joinedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
const threshold: ThresholdConfig = {
|
||||
t: prisma.thresholdT,
|
||||
n: prisma.thresholdN,
|
||||
};
|
||||
|
||||
return CoManagedWalletSessionEntity.reconstitute({
|
||||
id: prisma.id,
|
||||
walletName: prisma.walletName,
|
||||
threshold,
|
||||
inviteCode: prisma.inviteCode,
|
||||
status: prisma.status as WalletSessionStatus,
|
||||
participants,
|
||||
currentRound: prisma.currentRound,
|
||||
totalRounds: prisma.totalRounds,
|
||||
publicKey: prisma.publicKey,
|
||||
error: prisma.error,
|
||||
createdAt: prisma.createdAt,
|
||||
updatedAt: prisma.updatedAt,
|
||||
createdBy: prisma.createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将领域实体转换为 Prisma 持久化格式
|
||||
*/
|
||||
sessionToPersistence(
|
||||
domain: CoManagedWalletSessionEntity,
|
||||
): Omit<PrismaCoManagedWalletSession, 'createdAt' | 'updatedAt'> {
|
||||
return {
|
||||
id: domain.id,
|
||||
walletName: domain.walletName,
|
||||
thresholdT: domain.threshold.t,
|
||||
thresholdN: domain.threshold.n,
|
||||
inviteCode: domain.inviteCode,
|
||||
status: domain.status,
|
||||
participants: JSON.stringify(
|
||||
domain.participants.map((p) => ({
|
||||
partyId: p.partyId,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
joinedAt: p.joinedAt.toISOString(),
|
||||
})),
|
||||
),
|
||||
currentRound: domain.currentRound,
|
||||
totalRounds: domain.totalRounds,
|
||||
publicKey: domain.publicKey,
|
||||
error: domain.error,
|
||||
createdBy: domain.createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Prisma 钱包模型转换为领域实体
|
||||
*/
|
||||
walletToDomain(prisma: PrismaCoManagedWallet): CoManagedWalletEntity {
|
||||
const participants: Array<{ partyId: string; name: string }> = JSON.parse(
|
||||
prisma.participants,
|
||||
);
|
||||
|
||||
const threshold: ThresholdConfig = {
|
||||
t: prisma.thresholdT,
|
||||
n: prisma.thresholdN,
|
||||
};
|
||||
|
||||
return CoManagedWalletEntity.reconstitute({
|
||||
id: prisma.id,
|
||||
sessionId: prisma.sessionId,
|
||||
name: prisma.name,
|
||||
publicKey: prisma.publicKey,
|
||||
threshold,
|
||||
participants,
|
||||
createdAt: prisma.createdAt,
|
||||
createdBy: prisma.createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将领域实体转换为 Prisma 持久化格式
|
||||
*/
|
||||
walletToPersistence(
|
||||
domain: CoManagedWalletEntity,
|
||||
): Omit<PrismaCoManagedWallet, 'createdAt'> {
|
||||
return {
|
||||
id: domain.id,
|
||||
sessionId: domain.sessionId,
|
||||
name: domain.name,
|
||||
publicKey: domain.publicKey,
|
||||
thresholdT: domain.threshold.t,
|
||||
thresholdN: domain.threshold.n,
|
||||
participants: JSON.stringify(domain.participants),
|
||||
createdBy: domain.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CoManagedWalletSessionRepository,
|
||||
} from '../../../domain/repositories/co-managed-wallet.repository';
|
||||
import { CoManagedWalletSessionEntity } from '../../../domain/entities/co-managed-wallet.entity';
|
||||
import { WalletSessionStatus } from '../../../domain/enums/wallet-session-status.enum';
|
||||
import { CoManagedWalletMapper } from '../mappers/co-managed-wallet.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class CoManagedWalletSessionRepositoryImpl implements CoManagedWalletSessionRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: CoManagedWalletMapper,
|
||||
) {}
|
||||
|
||||
async save(entity: CoManagedWalletSessionEntity): Promise<CoManagedWalletSessionEntity> {
|
||||
const data = this.mapper.sessionToPersistence(entity);
|
||||
|
||||
const saved = await this.prisma.coManagedWalletSession.upsert({
|
||||
where: { id: entity.id },
|
||||
create: {
|
||||
...data,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
},
|
||||
update: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.mapper.sessionToDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CoManagedWalletSessionEntity | null> {
|
||||
const session = await this.prisma.coManagedWalletSession.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapper.sessionToDomain(session);
|
||||
}
|
||||
|
||||
async findByInviteCode(inviteCode: string): Promise<CoManagedWalletSessionEntity | null> {
|
||||
const session = await this.prisma.coManagedWalletSession.findUnique({
|
||||
where: { inviteCode },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapper.sessionToDomain(session);
|
||||
}
|
||||
|
||||
async findByCreatedBy(
|
||||
createdBy: string,
|
||||
status?: WalletSessionStatus,
|
||||
): Promise<CoManagedWalletSessionEntity[]> {
|
||||
const sessions = await this.prisma.coManagedWalletSession.findMany({
|
||||
where: {
|
||||
createdBy,
|
||||
...(status && { status }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return sessions.map((s) => this.mapper.sessionToDomain(s));
|
||||
}
|
||||
|
||||
async findAll(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: WalletSessionStatus;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletSessionEntity[];
|
||||
total: number;
|
||||
}> {
|
||||
const where = params.status ? { status: params.status } : {};
|
||||
|
||||
const [sessions, total] = await Promise.all([
|
||||
this.prisma.coManagedWalletSession.findMany({
|
||||
where,
|
||||
skip: (params.page - 1) * params.pageSize,
|
||||
take: params.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.coManagedWalletSession.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: sessions.map((s) => this.mapper.sessionToDomain(s)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.coManagedWalletSession.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CoManagedWalletRepository } from '../../../domain/repositories/co-managed-wallet.repository';
|
||||
import { CoManagedWalletEntity } from '../../../domain/entities/co-managed-wallet.entity';
|
||||
import { CoManagedWalletMapper } from '../mappers/co-managed-wallet.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class CoManagedWalletRepositoryImpl implements CoManagedWalletRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: CoManagedWalletMapper,
|
||||
) {}
|
||||
|
||||
async save(entity: CoManagedWalletEntity): Promise<CoManagedWalletEntity> {
|
||||
const data = this.mapper.walletToPersistence(entity);
|
||||
|
||||
const saved = await this.prisma.coManagedWallet.upsert({
|
||||
where: { id: entity.id },
|
||||
create: {
|
||||
...data,
|
||||
createdAt: entity.createdAt,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
|
||||
return this.mapper.walletToDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CoManagedWalletEntity | null> {
|
||||
const wallet = await this.prisma.coManagedWallet.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!wallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapper.walletToDomain(wallet);
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: string): Promise<CoManagedWalletEntity | null> {
|
||||
const wallet = await this.prisma.coManagedWallet.findUnique({
|
||||
where: { sessionId },
|
||||
});
|
||||
|
||||
if (!wallet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapper.walletToDomain(wallet);
|
||||
}
|
||||
|
||||
async findByCreatedBy(createdBy: string): Promise<CoManagedWalletEntity[]> {
|
||||
const wallets = await this.prisma.coManagedWallet.findMany({
|
||||
where: { createdBy },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return wallets.map((w) => this.mapper.walletToDomain(w));
|
||||
}
|
||||
|
||||
async findAll(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{
|
||||
items: CoManagedWalletEntity[];
|
||||
total: number;
|
||||
}> {
|
||||
const [wallets, total] = await Promise.all([
|
||||
this.prisma.coManagedWallet.findMany({
|
||||
skip: (params.page - 1) * params.pageSize,
|
||||
take: params.pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.coManagedWallet.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: wallets.map((w) => this.mapper.walletToDomain(w)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.coManagedWallet.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
# 分布式多方共管钱包创建功能实现计划
|
||||
|
||||
## 概述
|
||||
|
||||
为 RWADurian 项目实现分布式多方共管钱包创建功能,包括:
|
||||
1. **Admin-Web 扩展**: 在授权管理页面添加创建共管钱包入口
|
||||
2. **Service-Party 桌面应用**: 跨平台 Electron 应用,用户可在各自电脑上参与钱包创建
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
+-------------------+ +-------------------+ +-------------------+
|
||||
| Admin Web | | Service Party | | Service Party |
|
||||
| (发起者/管理员) | | Desktop App #1 | | Desktop App #2 |
|
||||
+--------+----------+ +--------+----------+ +--------+----------+
|
||||
| | |
|
||||
| HTTP/WebSocket | gRPC | gRPC
|
||||
| | |
|
||||
v v v
|
||||
+--------+-----------------------------+-----------------------------+----------+
|
||||
| Message Router (现有 gRPC 服务) |
|
||||
| backend/mpc-system/services/message-router |
|
||||
+--------+-----------------------------+-----------------------------+----------+
|
||||
| | |
|
||||
v v v
|
||||
+-------------------+ +----------------------------------------+
|
||||
| Session | | TSS Keygen Protocol (分布式执行) |
|
||||
| Coordinator | | 所有参与方通过 Message Router 交换消息|
|
||||
+-------------------+ +----------------------------------------+
|
||||
```
|
||||
|
||||
## 第一阶段:Admin-Web 扩展
|
||||
|
||||
### 1.1 修改授权管理页面
|
||||
|
||||
**文件**: `frontend/admin-web/src/app/(dashboard)/authorization/page.tsx`
|
||||
|
||||
添加"创建共管钱包"卡片区域:
|
||||
- 配置钱包名称
|
||||
- 配置阈值 (T-of-N)
|
||||
- 生成邀请链接/二维码
|
||||
- 实时显示参与方加入状态
|
||||
|
||||
### 1.2 新增组件
|
||||
|
||||
**目录**: `frontend/admin-web/src/components/features/co-managed-wallet/`
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `CreateWalletModal.tsx` | 创建钱包向导对话框 |
|
||||
| `ThresholdConfig.tsx` | 阈值配置 (T-of-N) |
|
||||
| `InviteQRCode.tsx` | 邀请二维码生成 |
|
||||
| `ParticipantList.tsx` | 参与方列表实时状态 |
|
||||
| `SessionProgress.tsx` | Keygen 进度显示 |
|
||||
| `WalletResult.tsx` | 创建结果展示 |
|
||||
|
||||
### 1.3 新增 API 服务
|
||||
|
||||
**文件**: `frontend/admin-web/src/services/coManagedWalletService.ts`
|
||||
|
||||
```typescript
|
||||
export const coManagedWalletService = {
|
||||
// 创建共管钱包会话
|
||||
createSession(params: {
|
||||
walletName: string;
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
}): Promise<SessionResponse>;
|
||||
|
||||
// 获取会话状态
|
||||
getSessionStatus(sessionId: string): Promise<SessionStatus>;
|
||||
|
||||
// WebSocket 订阅会话更新
|
||||
subscribeSession(sessionId: string, onUpdate: (status: SessionStatus) => void): () => void;
|
||||
};
|
||||
```
|
||||
|
||||
### 1.4 新增 React Query Hooks
|
||||
|
||||
**文件**: `frontend/admin-web/src/hooks/useCoManagedWallet.ts`
|
||||
|
||||
```typescript
|
||||
export function useCreateCoManagedWallet();
|
||||
export function useCoManagedWalletSession(sessionId: string);
|
||||
export function useCoManagedWalletList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:Service-Party 桌面应用
|
||||
|
||||
### 2.1 项目结构
|
||||
|
||||
**新项目目录**: `backend/mpc-system/services/service-party-app/`
|
||||
|
||||
```
|
||||
backend/mpc-system/services/service-party-app/
|
||||
├── electron/
|
||||
│ ├── main.ts # Electron 主进程
|
||||
│ ├── preload.ts # 预加载脚本
|
||||
│ └── modules/
|
||||
│ ├── grpc-client.ts # gRPC 客户端 (连接 Message Router)
|
||||
│ ├── tss-handler.ts # TSS 协议处理
|
||||
│ ├── storage.ts # 本地加密存储
|
||||
│ └── server.ts # 内置 HTTP 服务器
|
||||
├── src/
|
||||
│ ├── App.tsx
|
||||
│ ├── pages/
|
||||
│ │ ├── Home.tsx # 主页 - Share 列表
|
||||
│ │ ├── Join.tsx # 加入会话页面
|
||||
│ │ ├── Session.tsx # 会话进度页面
|
||||
│ │ └── Settings.tsx # 设置页面
|
||||
│ ├── components/
|
||||
│ │ ├── ShareCard.tsx # Share 卡片
|
||||
│ │ ├── JoinForm.tsx # 加入会话表单
|
||||
│ │ ├── ProgressSteps.tsx # 进度步骤
|
||||
│ │ ├── ExportDialog.tsx # 导出对话框
|
||||
│ │ └── ImportDialog.tsx # 导入对话框
|
||||
│ └── hooks/
|
||||
│ ├── useGrpc.ts # gRPC 连接 Hook
|
||||
│ ├── useSession.ts # 会话状态 Hook
|
||||
│ └── useStorage.ts # 本地存储 Hook
|
||||
├── wasm/
|
||||
│ └── tss_lib.wasm # TSS 库 WASM 编译版
|
||||
├── package.json
|
||||
├── electron-builder.json # Electron 打包配置
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### 2.2 技术选型
|
||||
|
||||
| 组件 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 桌面框架 | Electron 28+ | 跨平台支持 Windows/Mac/Linux |
|
||||
| 前端框架 | React 18 + TypeScript | 与 admin-web 保持一致 |
|
||||
| 状态管理 | Zustand | 轻量级状态管理 |
|
||||
| gRPC 客户端 | @grpc/grpc-js | Node.js gRPC 实现 |
|
||||
| TSS 协议 | Go -> WASM | 编译 tss-lib 到 WebAssembly |
|
||||
| 本地存储 | electron-store | 加密本地存储 |
|
||||
| 加密 | Node.js crypto | AES-256-GCM 加密 |
|
||||
| 打包 | electron-builder | 多平台打包 |
|
||||
|
||||
### 2.3 核心功能实现
|
||||
|
||||
#### gRPC 客户端模块
|
||||
|
||||
**文件**: `electron/modules/grpc-client.ts`
|
||||
|
||||
连接到现有 Message Router,实现以下接口:
|
||||
- `RegisterParty()` - 注册为参与方
|
||||
- `Heartbeat()` - 心跳保活
|
||||
- `SubscribeSessionEvents()` - 订阅会话事件
|
||||
- `JoinSession()` - 加入会话
|
||||
- `SubscribeMessages()` - 订阅 MPC 消息
|
||||
- `RouteMessage()` - 发送 MPC 消息
|
||||
- `ReportCompletion()` - 报告完成
|
||||
|
||||
#### TSS 协议处理
|
||||
|
||||
**文件**: `electron/modules/tss-handler.ts`
|
||||
|
||||
参考现有实现: `backend/mpc-system/services/server-party/application/use_cases/participate_keygen.go`
|
||||
|
||||
```typescript
|
||||
class TSSHandler {
|
||||
// 参与 keygen
|
||||
async participateKeygen(sessionInfo: SessionInfo): Promise<KeygenResult>;
|
||||
|
||||
// 参与 signing (未来扩展)
|
||||
async participateSigning(sessionInfo: SessionInfo, messageHash: string): Promise<SignResult>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 本地加密存储
|
||||
|
||||
**文件**: `electron/modules/storage.ts`
|
||||
|
||||
```typescript
|
||||
interface ShareEntry {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
walletName: string;
|
||||
partyId: string;
|
||||
partyIndex: number;
|
||||
threshold: { t: number; n: number };
|
||||
publicKey: string;
|
||||
encryptedShare: string; // AES-256-GCM 加密
|
||||
createdAt: string;
|
||||
metadata: {
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
class SecureStorage {
|
||||
saveShare(share: ShareEntry, password: string): void;
|
||||
loadShare(id: string, password: string): ShareEntry;
|
||||
listShares(): ShareEntry[];
|
||||
exportShare(id: string, password: string): Buffer; // 加密导出文件
|
||||
importShare(data: Buffer, password: string): ShareEntry;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 用户界面
|
||||
|
||||
#### 主页 (Share 列表)
|
||||
- 显示已保存的 share 列表
|
||||
- 每个 share 显示:钱包名称、公钥、创建时间、参与方数量
|
||||
- 操作:导出备份、删除
|
||||
|
||||
#### 加入会话页面
|
||||
- 输入/粘贴邀请链接
|
||||
- 或扫描二维码 (使用摄像头)
|
||||
- 输入参与者名称
|
||||
- 显示会话信息确认
|
||||
|
||||
#### 会话进度页面
|
||||
- 显示当前状态:等待其他参与方 / Keygen 进行中 / 完成
|
||||
- 参与方列表及其状态
|
||||
- 进度条显示 Keygen 轮次
|
||||
|
||||
#### 设置页面
|
||||
- Message Router 连接地址配置
|
||||
- 存储密码管理
|
||||
- 自动备份设置
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:后端扩展
|
||||
|
||||
### 3.1 Admin-Service API 扩展
|
||||
|
||||
**目录**: `backend/services/admin-service/src/modules/co-managed-wallet/`
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `co-managed-wallet.module.ts` | 模块定义 |
|
||||
| `co-managed-wallet.controller.ts` | REST 控制器 |
|
||||
| `co-managed-wallet.service.ts` | 业务逻辑 |
|
||||
| `co-managed-wallet.gateway.ts` | WebSocket 网关 |
|
||||
|
||||
**API 端点**:
|
||||
|
||||
```
|
||||
POST /api/v1/co-managed-wallets/sessions
|
||||
创建共管钱包会话
|
||||
|
||||
GET /api/v1/co-managed-wallets/sessions/:id
|
||||
获取会话状态
|
||||
|
||||
GET /api/v1/co-managed-wallets
|
||||
获取共管钱包列表
|
||||
|
||||
WebSocket /co-managed-wallets/:sessionId
|
||||
实时会话状态推送
|
||||
```
|
||||
|
||||
### 3.2 Session Coordinator 扩展
|
||||
|
||||
**文件**: `backend/mpc-system/services/session-coordinator/application/use_cases/create_co_managed_session.go`
|
||||
|
||||
扩展现有 `create_session.go`,支持:
|
||||
- `co_managed_keygen` 会话类型
|
||||
- 邀请码生成
|
||||
- 参与方名称管理
|
||||
|
||||
---
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Step 1: Admin-Web 基础 UI (2-3天)
|
||||
1. 在 authorization 页面添加"共管钱包"卡片
|
||||
2. 实现 CreateWalletModal 组件
|
||||
3. 实现阈值配置 UI
|
||||
|
||||
### Step 2: 后端 API (2-3天)
|
||||
1. 实现 admin-service 的共管钱包 API
|
||||
2. 扩展 Session Coordinator 支持新会话类型
|
||||
3. 实现 WebSocket 状态推送
|
||||
|
||||
### Step 3: Admin-Web 完善 (2天)
|
||||
1. 实现邀请链接/二维码生成
|
||||
2. 实现参与方列表实时更新
|
||||
3. 实现会话状态显示
|
||||
|
||||
### Step 4: Service-Party 脚手架 (2天)
|
||||
1. 创建 Electron 项目结构
|
||||
2. 配置 React + TypeScript
|
||||
3. 实现基础 UI 框架
|
||||
|
||||
### Step 5: gRPC 集成 (3天)
|
||||
1. 复制 proto 文件
|
||||
2. 实现 gRPC 客户端
|
||||
3. 实现会话订阅和消息处理
|
||||
|
||||
### Step 6: TSS 集成 (5天)
|
||||
1. 编译 tss-lib 到 WASM
|
||||
2. 实现 TSS 协议处理器
|
||||
3. 集成到 Electron 应用
|
||||
|
||||
### Step 7: 本地存储 (2天)
|
||||
1. 实现加密存储模块
|
||||
2. 实现导出/导入功能
|
||||
3. 实现备份下载
|
||||
|
||||
### Step 8: 测试与优化 (3天)
|
||||
1. 端到端测试
|
||||
2. 多机/多网络测试
|
||||
3. 错误处理优化
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 需要修改的现有文件
|
||||
|
||||
| 文件路径 | 修改内容 |
|
||||
|----------|----------|
|
||||
| `frontend/admin-web/src/app/(dashboard)/authorization/page.tsx` | 添加共管钱包入口卡片 |
|
||||
| `frontend/admin-web/src/infrastructure/api/endpoints.ts` | 添加共管钱包 API 端点 |
|
||||
| `backend/mpc-system/services/session-coordinator/domain/entities/session.go` | 扩展会话类型 |
|
||||
|
||||
### 需要新增的文件
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|----------|------|
|
||||
| `frontend/admin-web/src/components/features/co-managed-wallet/*` | 前端组件 (6个文件) |
|
||||
| `frontend/admin-web/src/services/coManagedWalletService.ts` | API 服务 |
|
||||
| `frontend/admin-web/src/hooks/useCoManagedWallet.ts` | React Query Hooks |
|
||||
| `backend/mpc-system/services/service-party-app/*` | 整个桌面应用项目 |
|
||||
| `backend/services/admin-service/src/modules/co-managed-wallet/*` | 后端模块 (4个文件) |
|
||||
|
||||
---
|
||||
|
||||
## 技术风险与解决方案
|
||||
|
||||
### 风险1: TSS 库编译到 WASM
|
||||
|
||||
**问题**: Go 的 tss-lib 使用了大量加密原语,编译到 WASM 可能有兼容性问题
|
||||
|
||||
**解决方案**:
|
||||
- 优先尝试 TinyGo 编译
|
||||
- 备选方案:编译为动态库 (.dll/.so/.dylib),通过 ffi-napi 调用
|
||||
|
||||
### 风险2: 网络连通性
|
||||
|
||||
**问题**: 参与方可能在 NAT 后或防火墙内
|
||||
|
||||
**解决方案**:
|
||||
- Message Router 部署在公网,所有客户端主动连接
|
||||
- gRPC 使用 HTTP/2,对防火墙友好
|
||||
- 实现断线重连和消息重发
|
||||
|
||||
### 风险3: Share 安全
|
||||
|
||||
**问题**: 本地存储的 share 可能被恶意软件窃取
|
||||
|
||||
**解决方案**:
|
||||
- AES-256-GCM 加密,密钥由用户密码派生 (PBKDF2)
|
||||
- 可选集成硬件安全密钥
|
||||
- 提醒用户备份到安全位置
|
||||
|
||||
---
|
||||
|
||||
## 预计工时
|
||||
|
||||
| 阶段 | 工时 |
|
||||
|------|------|
|
||||
| Admin-Web 扩展 | 5-7天 |
|
||||
| Service-Party 桌面应用 | 12-15天 |
|
||||
| 后端扩展 | 3-4天 |
|
||||
| 测试与优化 | 3-5天 |
|
||||
| **总计** | **23-31天** |
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { CoManagedWalletSection } from '@/components/features/co-managed-wallet';
|
||||
import styles from './authorization.module.scss';
|
||||
|
||||
/**
|
||||
|
|
@ -176,6 +177,9 @@ export default function AuthorizationPage() {
|
|||
return (
|
||||
<PageContainer title="授权管理">
|
||||
<div className={styles.authorization}>
|
||||
{/* 共管钱包管理 */}
|
||||
<CoManagedWalletSection />
|
||||
|
||||
{/* 授权省公司管理 */}
|
||||
<section className={styles.authorization__card}>
|
||||
<h3 className={styles.authorization__cardTitle}>授权省公司管理</h3>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
import CreateWalletModal from './CreateWalletModal';
|
||||
|
||||
interface CoManagedWallet {
|
||||
id: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
threshold: { t: number; n: number };
|
||||
participantCount: number;
|
||||
createdAt: string;
|
||||
status: 'active' | 'pending';
|
||||
}
|
||||
|
||||
// 模拟数据
|
||||
const mockWallets: CoManagedWallet[] = [];
|
||||
|
||||
/**
|
||||
* 共管钱包管理区域组件
|
||||
*/
|
||||
export default function CoManagedWalletSection() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [wallets] = useState<CoManagedWallet[]>(mockWallets);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const truncateKey = (key: string) => {
|
||||
if (key.length <= 20) return key;
|
||||
return `${key.slice(0, 10)}...${key.slice(-8)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.coManagedWalletSection}>
|
||||
<div className={styles.coManagedWalletSection__header}>
|
||||
<div>
|
||||
<h3 className={styles.coManagedWalletSection__title}>共管钱包管理</h3>
|
||||
<p className={styles.coManagedWalletSection__desc}>
|
||||
创建和管理分布式多方共管钱包,支持多人协同签名
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.coManagedWalletSection__createBtn}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
创建共管钱包
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wallets.length === 0 ? (
|
||||
<div className={styles.coManagedWalletSection__empty}>
|
||||
<div className={styles.coManagedWalletSection__emptyIcon}>🔐</div>
|
||||
<p className={styles.coManagedWalletSection__emptyText}>
|
||||
暂无共管钱包
|
||||
</p>
|
||||
<p className={styles.coManagedWalletSection__emptyHint}>
|
||||
创建共管钱包后,多个参与方可以共同管理资产
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.coManagedWalletSection__emptyBtn}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
创建第一个共管钱包
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.coManagedWalletSection__grid}>
|
||||
{wallets.map(wallet => (
|
||||
<div key={wallet.id} className={styles.walletCard}>
|
||||
<div className={styles.walletCard__header}>
|
||||
<h4 className={styles.walletCard__name}>{wallet.name}</h4>
|
||||
<span className={styles.walletCard__threshold}>
|
||||
{wallet.threshold.t}-of-{wallet.threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.walletCard__body}>
|
||||
<div className={styles.walletCard__row}>
|
||||
<span className={styles.walletCard__label}>公钥</span>
|
||||
<code className={styles.walletCard__value}>
|
||||
{truncateKey(wallet.publicKey)}
|
||||
</code>
|
||||
</div>
|
||||
<div className={styles.walletCard__row}>
|
||||
<span className={styles.walletCard__label}>参与方</span>
|
||||
<span className={styles.walletCard__value}>
|
||||
{wallet.participantCount} 人
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.walletCard__row}>
|
||||
<span className={styles.walletCard__label}>创建时间</span>
|
||||
<span className={styles.walletCard__value}>
|
||||
{formatDate(wallet.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.walletCard__footer}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.walletCard__actionBtn}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={styles.coManagedWalletSection__help}>
|
||||
帮助:共管钱包使用 MPC (多方计算) 技术,无需暴露完整私钥即可进行安全签名。
|
||||
每个参与方持有密钥份额,需要达到阈值数量的参与方才能完成签名。
|
||||
</p>
|
||||
|
||||
<CreateWalletModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import ThresholdConfig from './ThresholdConfig';
|
||||
import InviteQRCode from './InviteQRCode';
|
||||
import ParticipantList from './ParticipantList';
|
||||
import SessionProgress from './SessionProgress';
|
||||
import WalletResult from './WalletResult';
|
||||
|
||||
interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
joinedAt?: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
inviteCode: string;
|
||||
inviteUrl: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
participants: Participant[];
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface CreateWalletModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Step = 'config' | 'invite' | 'progress' | 'result';
|
||||
|
||||
/**
|
||||
* 创建共管钱包向导对话框
|
||||
*/
|
||||
export default function CreateWalletModal({ isOpen, onClose }: CreateWalletModalProps) {
|
||||
const [step, setStep] = useState<Step>('config');
|
||||
const [walletName, setWalletName] = useState('');
|
||||
const [thresholdT, setThresholdT] = useState(2);
|
||||
const [thresholdN, setThresholdN] = useState(3);
|
||||
const [initiatorName, setInitiatorName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStep('config');
|
||||
setWalletName('');
|
||||
setThresholdT(2);
|
||||
setThresholdN(3);
|
||||
setInitiatorName('');
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setSession(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 创建会话
|
||||
const handleCreateSession = async () => {
|
||||
if (!walletName.trim()) {
|
||||
setError('请输入钱包名称');
|
||||
return;
|
||||
}
|
||||
if (!initiatorName.trim()) {
|
||||
setError('请输入您的名称');
|
||||
return;
|
||||
}
|
||||
if (thresholdT > thresholdN) {
|
||||
setError('签名阈值不能大于参与方总数');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: 调用 API 创建会话
|
||||
// const result = await coManagedWalletService.createSession({
|
||||
// walletName: walletName.trim(),
|
||||
// thresholdT,
|
||||
// thresholdN,
|
||||
// initiatorName: initiatorName.trim(),
|
||||
// });
|
||||
|
||||
// 模拟创建成功
|
||||
const mockSession: SessionState = {
|
||||
sessionId: 'session-' + Date.now(),
|
||||
inviteCode: 'ABCD-1234-EFGH',
|
||||
inviteUrl: `https://app.rwadurian.com/join/ABCD-1234-EFGH`,
|
||||
status: 'waiting',
|
||||
participants: [
|
||||
{
|
||||
partyId: 'party-1',
|
||||
name: initiatorName.trim(),
|
||||
status: 'ready',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
currentRound: 0,
|
||||
totalRounds: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setSession(mockSession);
|
||||
setStep('invite');
|
||||
} catch (err) {
|
||||
setError('创建会话失败,请重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始密钥生成
|
||||
const handleStartKeygen = async () => {
|
||||
if (!session) return;
|
||||
|
||||
setStep('progress');
|
||||
setSession(prev => prev ? { ...prev, status: 'processing' } : null);
|
||||
|
||||
// TODO: 监听会话状态更新
|
||||
// 模拟进度更新
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setSession(prev => prev ? { ...prev, currentRound: i } : null);
|
||||
}
|
||||
|
||||
// 模拟完成
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: 'completed',
|
||||
publicKey: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
} : null);
|
||||
setStep('result');
|
||||
};
|
||||
|
||||
// 渲染步骤指示器
|
||||
const renderStepIndicator = () => {
|
||||
const steps = [
|
||||
{ key: 'config', label: '配置' },
|
||||
{ key: 'invite', label: '邀请' },
|
||||
{ key: 'progress', label: '生成' },
|
||||
{ key: 'result', label: '完成' },
|
||||
];
|
||||
|
||||
const currentIndex = steps.findIndex(s => s.key === step);
|
||||
|
||||
return (
|
||||
<div className={styles.createWalletModal__steps}>
|
||||
{steps.map((s, index) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn(
|
||||
styles.createWalletModal__step,
|
||||
index < currentIndex && styles['createWalletModal__step--completed'],
|
||||
index === currentIndex && styles['createWalletModal__step--active']
|
||||
)}
|
||||
>
|
||||
<div className={styles.createWalletModal__stepNumber}>{index + 1}</div>
|
||||
<span className={styles.createWalletModal__stepLabel}>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose}>
|
||||
<div className={styles.createWalletModal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.createWalletModal__header}>
|
||||
<h2 className={styles.createWalletModal__title}>创建共管钱包</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createWalletModal__closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderStepIndicator()}
|
||||
|
||||
<div className={styles.createWalletModal__content}>
|
||||
{step === 'config' && (
|
||||
<div className={styles.createWalletModal__form}>
|
||||
<div className={styles.createWalletModal__field}>
|
||||
<label className={styles.createWalletModal__label}>钱包名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={walletName}
|
||||
onChange={e => setWalletName(e.target.value)}
|
||||
placeholder="为您的共管钱包命名"
|
||||
className={styles.createWalletModal__input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.createWalletModal__field}>
|
||||
<label className={styles.createWalletModal__label}>阈值设置 (T-of-N)</label>
|
||||
<ThresholdConfig
|
||||
thresholdT={thresholdT}
|
||||
thresholdN={thresholdN}
|
||||
onThresholdTChange={setThresholdT}
|
||||
onThresholdNChange={setThresholdN}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className={styles.createWalletModal__hint}>
|
||||
需要 {thresholdT} 个参与方共同签名才能执行交易
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.createWalletModal__field}>
|
||||
<label className={styles.createWalletModal__label}>您的名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={initiatorName}
|
||||
onChange={e => setInitiatorName(e.target.value)}
|
||||
placeholder="输入您的名称(其他参与者可见)"
|
||||
className={styles.createWalletModal__input}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.createWalletModal__error}>{error}</p>}
|
||||
|
||||
<div className={styles.createWalletModal__actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createWalletModal__cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createWalletModal__submitBtn}
|
||||
onClick={handleCreateSession}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '创建中...' : '创建会话'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'invite' && session && (
|
||||
<div className={styles.createWalletModal__invite}>
|
||||
<InviteQRCode
|
||||
inviteCode={session.inviteCode}
|
||||
inviteUrl={session.inviteUrl}
|
||||
/>
|
||||
|
||||
<ParticipantList
|
||||
participants={session.participants}
|
||||
totalRequired={thresholdN}
|
||||
/>
|
||||
|
||||
<div className={styles.createWalletModal__actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createWalletModal__cancelBtn}
|
||||
onClick={() => setStep('config')}
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createWalletModal__submitBtn}
|
||||
onClick={handleStartKeygen}
|
||||
disabled={session.participants.length < thresholdN}
|
||||
>
|
||||
{session.participants.length < thresholdN
|
||||
? `等待参与方 (${session.participants.length}/${thresholdN})`
|
||||
: '开始生成密钥'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'progress' && session && (
|
||||
<div className={styles.createWalletModal__progress}>
|
||||
<SessionProgress
|
||||
status={session.status}
|
||||
currentRound={session.currentRound}
|
||||
totalRounds={session.totalRounds}
|
||||
error={session.error}
|
||||
/>
|
||||
|
||||
<ParticipantList
|
||||
participants={session.participants}
|
||||
totalRequired={thresholdN}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && session && session.publicKey && (
|
||||
<WalletResult
|
||||
walletName={walletName}
|
||||
publicKey={session.publicKey}
|
||||
threshold={{ t: thresholdT, n: thresholdN }}
|
||||
participants={session.participants}
|
||||
createdAt={session.createdAt || new Date().toISOString()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
|
||||
interface InviteQRCodeProps {
|
||||
inviteCode: string;
|
||||
inviteUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 邀请二维码组件
|
||||
*/
|
||||
export default function InviteQRCode({ inviteCode, inviteUrl }: InviteQRCodeProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 动态加载 QR 码生成
|
||||
generateQRCode(inviteUrl);
|
||||
}, [inviteUrl]);
|
||||
|
||||
const generateQRCode = async (url: string) => {
|
||||
try {
|
||||
// 使用简单的 QR 码 API (可替换为本地库)
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(url)}`;
|
||||
setQrDataUrl(qrApiUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteCode);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.inviteQrCode}>
|
||||
<div className={styles.inviteQrCode__qrWrapper}>
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="邀请二维码"
|
||||
className={styles.inviteQrCode__qr}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.inviteQrCode__placeholder}>
|
||||
生成中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.inviteQrCode__info}>
|
||||
<div className={styles.inviteQrCode__field}>
|
||||
<label className={styles.inviteQrCode__label}>邀请码</label>
|
||||
<div className={styles.inviteQrCode__codeWrapper}>
|
||||
<code className={styles.inviteQrCode__code}>{inviteCode}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.inviteQrCode__copyBtn}
|
||||
onClick={handleCopyCode}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.inviteQrCode__field}>
|
||||
<label className={styles.inviteQrCode__label}>邀请链接</label>
|
||||
<div className={styles.inviteQrCode__urlWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteUrl}
|
||||
readOnly
|
||||
className={styles.inviteQrCode__url}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.inviteQrCode__copyBtn}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.inviteQrCode__hint}>
|
||||
将邀请码或链接分享给其他参与方,他们可以使用 Service Party 应用加入
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
'use client';
|
||||
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
import { cn } from '@/utils/helpers';
|
||||
|
||||
interface Participant {
|
||||
partyId: string;
|
||||
name: string;
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
joinedAt?: string;
|
||||
}
|
||||
|
||||
interface ParticipantListProps {
|
||||
participants: Participant[];
|
||||
totalRequired: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参与方列表组件
|
||||
*/
|
||||
export default function ParticipantList({ participants, totalRequired }: ParticipantListProps) {
|
||||
const getStatusText = (status: Participant['status']) => {
|
||||
switch (status) {
|
||||
case 'waiting':
|
||||
return '等待中';
|
||||
case 'ready':
|
||||
return '已就绪';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: Participant['status']) => {
|
||||
switch (status) {
|
||||
case 'waiting':
|
||||
return '⏳';
|
||||
case 'ready':
|
||||
return '✓';
|
||||
case 'processing':
|
||||
return '⚡';
|
||||
case 'completed':
|
||||
return '✓';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
};
|
||||
|
||||
const emptySlots = totalRequired - participants.length;
|
||||
|
||||
return (
|
||||
<div className={styles.participantList}>
|
||||
<div className={styles.participantList__header}>
|
||||
<span className={styles.participantList__title}>
|
||||
参与方 ({participants.length} / {totalRequired})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.participantList__items}>
|
||||
{participants.map((participant, index) => (
|
||||
<div key={participant.partyId} className={styles.participantList__item}>
|
||||
<div className={styles.participantList__info}>
|
||||
<span className={styles.participantList__index}>#{index + 1}</span>
|
||||
<span className={styles.participantList__name}>{participant.name}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
styles.participantList__status,
|
||||
styles[`participantList__status--${participant.status}`]
|
||||
)}
|
||||
>
|
||||
<span className={styles.participantList__statusIcon}>
|
||||
{getStatusIcon(participant.status)}
|
||||
</span>
|
||||
{getStatusText(participant.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from({ length: emptySlots }).map((_, index) => (
|
||||
<div
|
||||
key={`empty-${index}`}
|
||||
className={cn(styles.participantList__item, styles['participantList__item--empty'])}
|
||||
>
|
||||
<div className={styles.participantList__info}>
|
||||
<span className={styles.participantList__index}>
|
||||
#{participants.length + index + 1}
|
||||
</span>
|
||||
<span className={styles.participantList__name}>等待加入...</span>
|
||||
</div>
|
||||
<span className={styles.participantList__statusIcon}>⏳</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use client';
|
||||
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
import { cn } from '@/utils/helpers';
|
||||
|
||||
interface SessionProgressProps {
|
||||
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
currentRound?: number;
|
||||
totalRounds?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话进度组件
|
||||
*/
|
||||
export default function SessionProgress({
|
||||
status,
|
||||
currentRound = 0,
|
||||
totalRounds = 3,
|
||||
error,
|
||||
}: SessionProgressProps) {
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'waiting':
|
||||
return '等待参与方加入';
|
||||
case 'ready':
|
||||
return '准备就绪,即将开始';
|
||||
case 'processing':
|
||||
return '密钥生成中';
|
||||
case 'completed':
|
||||
return '创建完成';
|
||||
case 'failed':
|
||||
return '创建失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = status === 'processing' && totalRounds > 0
|
||||
? (currentRound / totalRounds) * 100
|
||||
: status === 'completed'
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.sessionProgress}>
|
||||
<div className={styles.sessionProgress__header}>
|
||||
<span
|
||||
className={cn(
|
||||
styles.sessionProgress__status,
|
||||
styles[`sessionProgress__status--${status}`]
|
||||
)}
|
||||
>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
{status === 'processing' && (
|
||||
<span className={styles.sessionProgress__round}>
|
||||
第 {currentRound} / {totalRounds} 轮
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.sessionProgress__bar}>
|
||||
<div
|
||||
className={cn(
|
||||
styles.sessionProgress__fill,
|
||||
status === 'completed' && styles['sessionProgress__fill--completed'],
|
||||
status === 'failed' && styles['sessionProgress__fill--failed']
|
||||
)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'processing' && (
|
||||
<p className={styles.sessionProgress__hint}>
|
||||
正在与其他参与方进行安全的密钥生成协议,请勿关闭页面
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'failed' && error && (
|
||||
<p className={styles.sessionProgress__error}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
'use client';
|
||||
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
|
||||
interface ThresholdConfigProps {
|
||||
thresholdT: number;
|
||||
thresholdN: number;
|
||||
onThresholdTChange: (value: number) => void;
|
||||
onThresholdNChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值配置组件 (T-of-N)
|
||||
*/
|
||||
export default function ThresholdConfig({
|
||||
thresholdT,
|
||||
thresholdN,
|
||||
onThresholdTChange,
|
||||
onThresholdNChange,
|
||||
disabled = false,
|
||||
}: ThresholdConfigProps) {
|
||||
const handleTDecrease = () => {
|
||||
if (thresholdT > 1) {
|
||||
onThresholdTChange(thresholdT - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTIncrease = () => {
|
||||
if (thresholdT < thresholdN) {
|
||||
onThresholdTChange(thresholdT + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNDecrease = () => {
|
||||
if (thresholdN > 2) {
|
||||
const newN = thresholdN - 1;
|
||||
onThresholdNChange(newN);
|
||||
if (thresholdT > newN) {
|
||||
onThresholdTChange(newN);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNIncrease = () => {
|
||||
if (thresholdN < 10) {
|
||||
onThresholdNChange(thresholdN + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.thresholdConfig}>
|
||||
<div className={styles.thresholdConfig__item}>
|
||||
<span className={styles.thresholdConfig__label}>签名阈值 (T)</span>
|
||||
<div className={styles.thresholdConfig__control}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.thresholdConfig__btn}
|
||||
onClick={handleTDecrease}
|
||||
disabled={disabled || thresholdT <= 1}
|
||||
aria-label="减少签名阈值"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className={styles.thresholdConfig__value}>{thresholdT}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.thresholdConfig__btn}
|
||||
onClick={handleTIncrease}
|
||||
disabled={disabled || thresholdT >= thresholdN}
|
||||
aria-label="增加签名阈值"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.thresholdConfig__divider}>of</div>
|
||||
|
||||
<div className={styles.thresholdConfig__item}>
|
||||
<span className={styles.thresholdConfig__label}>参与方总数 (N)</span>
|
||||
<div className={styles.thresholdConfig__control}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.thresholdConfig__btn}
|
||||
onClick={handleNDecrease}
|
||||
disabled={disabled || thresholdN <= 2}
|
||||
aria-label="减少参与方总数"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className={styles.thresholdConfig__value}>{thresholdN}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.thresholdConfig__btn}
|
||||
onClick={handleNIncrease}
|
||||
disabled={disabled || thresholdN >= 10}
|
||||
aria-label="增加参与方总数"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import styles from './co-managed-wallet.module.scss';
|
||||
|
||||
interface WalletResultProps {
|
||||
walletName: string;
|
||||
publicKey: string;
|
||||
threshold: { t: number; n: number };
|
||||
participants: Array<{ partyId: string; name: string }>;
|
||||
createdAt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钱包创建结果组件
|
||||
*/
|
||||
export default function WalletResult({
|
||||
walletName,
|
||||
publicKey,
|
||||
threshold,
|
||||
participants,
|
||||
createdAt,
|
||||
onClose,
|
||||
}: WalletResultProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyPublicKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(publicKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.walletResult}>
|
||||
<div className={styles.walletResult__successIcon}>✓</div>
|
||||
<h3 className={styles.walletResult__title}>共管钱包创建成功</h3>
|
||||
|
||||
<div className={styles.walletResult__info}>
|
||||
<div className={styles.walletResult__row}>
|
||||
<span className={styles.walletResult__label}>钱包名称</span>
|
||||
<span className={styles.walletResult__value}>{walletName}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__row}>
|
||||
<span className={styles.walletResult__label}>阈值设置</span>
|
||||
<span className={styles.walletResult__value}>
|
||||
{threshold.t}-of-{threshold.n}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__row}>
|
||||
<span className={styles.walletResult__label}>创建时间</span>
|
||||
<span className={styles.walletResult__value}>{formatDate(createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__row}>
|
||||
<span className={styles.walletResult__label}>参与方</span>
|
||||
<span className={styles.walletResult__value}>
|
||||
{participants.map(p => p.name).join('、')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__publicKey}>
|
||||
<label className={styles.walletResult__label}>钱包公钥</label>
|
||||
<div className={styles.walletResult__keyWrapper}>
|
||||
<code className={styles.walletResult__key}>{publicKey}</code>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.walletResult__copyBtn}
|
||||
onClick={handleCopyPublicKey}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__notice}>
|
||||
<strong>重要提示:</strong>
|
||||
<ul>
|
||||
<li>每个参与方的密钥份额已保存在各自的 Service Party 应用中</li>
|
||||
<li>请确保所有参与方都已备份各自的密钥份额</li>
|
||||
<li>签名交易时需要至少 {threshold.t} 个参与方共同参与</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.walletResult__actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.walletResult__closeBtn}
|
||||
onClick={onClose}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,963 @@
|
|||
/* 共管钱包组件样式 */
|
||||
@use '@/styles/variables' as *;
|
||||
@use '@/styles/mixins' as *;
|
||||
|
||||
/* ============================================
|
||||
共管钱包管理区域
|
||||
============================================ */
|
||||
.coManagedWalletSection {
|
||||
align-self: stretch;
|
||||
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
gap: 20px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__createBtn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #005a9c;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__emptyText {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__emptyHint {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
&__emptyBtn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: #005a9c;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
&__help {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
钱包卡片
|
||||
============================================ */
|
||||
.walletCard {
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__threshold {
|
||||
padding: 4px 10px;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__actionBtn {
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
color: #005a9c;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: #005a9c;
|
||||
background-color: rgba(0, 90, 156, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
模态框
|
||||
============================================ */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.createWalletModal {
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__closeBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 24px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
&__steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 24px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
&__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
opacity: 0.5;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
opacity: 1;
|
||||
|
||||
.createWalletModal__stepNumber {
|
||||
background-color: #22c55e;
|
||||
border-color: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__stepNumber {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__step--active &__stepNumber {
|
||||
border-color: #005a9c;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__stepLabel {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__step--active &__stepLabel {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
background-color: #f9fafb;
|
||||
outline: none;
|
||||
@include transition-fast;
|
||||
|
||||
&:focus {
|
||||
border-color: #005a9c;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: #dc2626;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__cancelBtn {
|
||||
padding: 10px 20px;
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #005a9c;
|
||||
color: #005a9c;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__submitBtn {
|
||||
padding: 10px 20px;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__invite,
|
||||
&__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
阈值配置
|
||||
============================================ */
|
||||
.thresholdConfig {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #005a9c;
|
||||
color: #005a9c;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #005a9c;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
邀请二维码
|
||||
============================================ */
|
||||
.inviteQrCode {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
|
||||
@media (max-width: 540px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__qrWrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__qr {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__codeWrapper,
|
||||
&__urlWrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__code {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__url {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__copyBtn {
|
||||
padding: 8px 14px;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
参与方列表
|
||||
============================================ */
|
||||
.participantList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
|
||||
&--empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__index {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
|
||||
&--waiting {
|
||||
background-color: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--ready {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--processing {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
&__statusIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
会话进度
|
||||
============================================ */
|
||||
.sessionProgress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
&--waiting {
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
&--ready {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--processing {
|
||||
color: #005a9c;
|
||||
}
|
||||
|
||||
&--completed {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
&__round {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
height: 8px;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
background-color: #005a9c;
|
||||
border-radius: 9999px;
|
||||
@include transition-fast;
|
||||
|
||||
&--completed {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: #dc2626;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
钱包结果
|
||||
============================================ */
|
||||
.walletResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
&__successIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
&__info {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__publicKey {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__keyWrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__key {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__copyBtn {
|
||||
padding: 10px 14px;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&__notice {
|
||||
align-self: stretch;
|
||||
padding: 16px;
|
||||
background-color: #fef9c3;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #854d0e;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__closeBtn {
|
||||
padding: 12px 32px;
|
||||
background-color: #005a9c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@include transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#005a9c, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Co-Managed Wallet Components
|
||||
export { default as CreateWalletModal } from './CreateWalletModal';
|
||||
export { default as ThresholdConfig } from './ThresholdConfig';
|
||||
export { default as InviteQRCode } from './InviteQRCode';
|
||||
export { default as ParticipantList } from './ParticipantList';
|
||||
export { default as SessionProgress } from './SessionProgress';
|
||||
export { default as WalletResult } from './WalletResult';
|
||||
export { default as CoManagedWalletSection } from './CoManagedWalletSection';
|
||||
|
|
@ -145,4 +145,12 @@ export const API_ENDPOINTS = {
|
|||
DELETE: (id: string) => `/v1/admin/segments/${id}`,
|
||||
REFRESH: (id: string) => `/v1/admin/segments/${id}/refresh`,
|
||||
},
|
||||
|
||||
// 共管钱包 (admin-service)
|
||||
CO_MANAGED_WALLETS: {
|
||||
LIST: '/v1/admin/co-managed-wallets',
|
||||
CREATE_SESSION: '/v1/admin/co-managed-wallets/sessions',
|
||||
SESSION_DETAIL: (sessionId: string) => `/v1/admin/co-managed-wallets/sessions/${sessionId}`,
|
||||
WALLET_DETAIL: (walletId: string) => `/v1/admin/co-managed-wallets/${walletId}`,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
Loading…
Reference in New Issue