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=\"referral\" --passWithNoTests)",
|
||||||
"Bash(npx jest --testPathPattern=\"wallet\" --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''\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": [],
|
"deny": [],
|
||||||
"ask": []
|
"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, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
INSERT INTO mpc_sessions (
|
INSERT INTO mpc_sessions (
|
||||||
id, session_type, threshold_n, threshold_t, status,
|
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,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
message_hash = EXCLUDED.message_hash,
|
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,
|
keygen_session_id = EXCLUDED.keygen_session_id,
|
||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
completed_at = EXCLUDED.completed_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(),
|
session.ID.UUID(),
|
||||||
string(session.SessionType),
|
string(session.SessionType),
|
||||||
|
|
@ -64,6 +67,8 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
|
||||||
session.ExpiresAt,
|
session.ExpiresAt,
|
||||||
session.CompletedAt,
|
session.CompletedAt,
|
||||||
session.Version,
|
session.Version,
|
||||||
|
session.WalletName,
|
||||||
|
session.InviteCode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -120,7 +125,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
||||||
var session sessionRow
|
var session sessionRow
|
||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
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
|
FROM mpc_sessions WHERE id = $1
|
||||||
`, id).Scan(
|
`, id).Scan(
|
||||||
&session.ID,
|
&session.ID,
|
||||||
|
|
@ -138,6 +144,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
||||||
&session.ExpiresAt,
|
&session.ExpiresAt,
|
||||||
&session.CompletedAt,
|
&session.CompletedAt,
|
||||||
&session.Version,
|
&session.Version,
|
||||||
|
&session.WalletName,
|
||||||
|
&session.InviteCode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
|
@ -175,6 +183,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
|
||||||
session.CompletedAt,
|
session.CompletedAt,
|
||||||
participants,
|
participants,
|
||||||
session.Version,
|
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) {
|
func (r *SessionPostgresRepo) FindByStatus(ctx context.Context, status value_objects.SessionStatus) ([]*entities.MPCSession, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
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
|
FROM mpc_sessions WHERE status = $1
|
||||||
`, status.String())
|
`, status.String())
|
||||||
if err != nil {
|
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) {
|
func (r *SessionPostgresRepo) FindExpired(ctx context.Context) ([]*entities.MPCSession, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
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
|
FROM mpc_sessions
|
||||||
WHERE expires_at < NOW() AND status IN ('created', 'in_progress')
|
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) {
|
func (r *SessionPostgresRepo) FindActive(ctx context.Context) ([]*entities.MPCSession, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
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
|
FROM mpc_sessions
|
||||||
WHERE status IN ('created', 'in_progress')
|
WHERE status IN ('created', 'in_progress')
|
||||||
ORDER BY created_at ASC
|
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) {
|
func (r *SessionPostgresRepo) FindByCreator(ctx context.Context, creatorID string) ([]*entities.MPCSession, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id, session_type, threshold_n, threshold_t, status,
|
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
|
FROM mpc_sessions WHERE created_by = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`, creatorID)
|
`, 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) {
|
func (r *SessionPostgresRepo) FindActiveByParticipant(ctx context.Context, partyID value_objects.PartyID) ([]*entities.MPCSession, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT s.id, s.session_type, s.threshold_n, s.threshold_t, s.status,
|
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
|
FROM mpc_sessions s
|
||||||
JOIN participants p ON s.id = p.session_id
|
JOIN participants p ON s.id = p.session_id
|
||||||
WHERE p.party_id = $1 AND s.status IN ('created', 'in_progress')
|
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.ExpiresAt,
|
||||||
&s.CompletedAt,
|
&s.CompletedAt,
|
||||||
&s.Version,
|
&s.Version,
|
||||||
|
&s.WalletName,
|
||||||
|
&s.InviteCode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -537,6 +554,8 @@ func (r *SessionPostgresRepo) scanSessions(ctx context.Context, rows *sql.Rows)
|
||||||
s.CompletedAt,
|
s.CompletedAt,
|
||||||
participants,
|
participants,
|
||||||
s.Version,
|
s.Version,
|
||||||
|
s.WalletName,
|
||||||
|
s.InviteCode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -564,6 +583,8 @@ type sessionRow struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
Version int64
|
Version int64
|
||||||
|
WalletName string // 钱包名称 (for co_managed_keygen)
|
||||||
|
InviteCode string // 邀请码 (for co_managed_keygen)
|
||||||
}
|
}
|
||||||
|
|
||||||
type participantRow struct {
|
type participantRow struct {
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,8 @@ type sessionCacheEntry struct {
|
||||||
ExpiresAt int64 `json:"expires_at"`
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
CompletedAt *int64 `json:"completed_at,omitempty"`
|
CompletedAt *int64 `json:"completed_at,omitempty"`
|
||||||
Participants []participantCacheEntry `json:"participants"`
|
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 {
|
type participantCacheEntry struct {
|
||||||
|
|
@ -214,6 +216,8 @@ func sessionToCacheEntry(s *entities.MPCSession) sessionCacheEntry {
|
||||||
ExpiresAt: s.ExpiresAt.UnixMilli(),
|
ExpiresAt: s.ExpiresAt.UnixMilli(),
|
||||||
CompletedAt: completedAt,
|
CompletedAt: completedAt,
|
||||||
Participants: participants,
|
Participants: participants,
|
||||||
|
WalletName: s.WalletName,
|
||||||
|
InviteCode: s.InviteCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +270,7 @@ func cacheEntryToSession(entry sessionCacheEntry) (*entities.MPCSession, error)
|
||||||
entry.MessageHash,
|
entry.MessageHash,
|
||||||
entry.PublicKey,
|
entry.PublicKey,
|
||||||
"", // delegatePartyID - not cached
|
"", // delegatePartyID - not cached
|
||||||
|
uuid.Nil, // keygenSessionID - not cached
|
||||||
entry.CreatedBy,
|
entry.CreatedBy,
|
||||||
time.UnixMilli(entry.CreatedAt),
|
time.UnixMilli(entry.CreatedAt),
|
||||||
time.UnixMilli(entry.UpdatedAt),
|
time.UnixMilli(entry.UpdatedAt),
|
||||||
|
|
@ -273,6 +278,8 @@ func cacheEntryToSession(entry sessionCacheEntry) (*entities.MPCSession, error)
|
||||||
completedAt,
|
completedAt,
|
||||||
participants,
|
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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,17 @@ type SessionType string
|
||||||
const (
|
const (
|
||||||
SessionTypeKeygen SessionType = "keygen"
|
SessionTypeKeygen SessionType = "keygen"
|
||||||
SessionTypeSign SessionType = "sign"
|
SessionTypeSign SessionType = "sign"
|
||||||
|
SessionTypeCoManagedKeygen SessionType = "co_managed_keygen" // 共管钱包密钥生成
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsValid checks if the session type is valid
|
// IsValid checks if the session type is valid
|
||||||
func (t SessionType) IsValid() bool {
|
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
|
// MPCSession represents an MPC session
|
||||||
|
|
@ -52,6 +58,10 @@ type MPCSession struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CompletedAt *time.Time
|
CompletedAt *time.Time
|
||||||
Version int64 // Optimistic locking version number
|
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
|
// NewMPCSession creates a new MPC session
|
||||||
|
|
@ -354,6 +364,8 @@ func (s *MPCSession) ToDTO() SessionDTO {
|
||||||
Status: s.Status.String(),
|
Status: s.Status.String(),
|
||||||
CreatedAt: s.CreatedAt,
|
CreatedAt: s.CreatedAt,
|
||||||
ExpiresAt: s.ExpiresAt,
|
ExpiresAt: s.ExpiresAt,
|
||||||
|
WalletName: s.WalletName,
|
||||||
|
InviteCode: s.InviteCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,6 +379,8 @@ type SessionDTO struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_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
|
// ParticipantDTO is a data transfer object for participants
|
||||||
|
|
@ -391,6 +405,8 @@ func ReconstructSession(
|
||||||
completedAt *time.Time,
|
completedAt *time.Time,
|
||||||
participants []*Participant,
|
participants []*Participant,
|
||||||
version int64,
|
version int64,
|
||||||
|
walletName string, // 钱包名称 (for co_managed_keygen)
|
||||||
|
inviteCode string, // 邀请码 (for co_managed_keygen)
|
||||||
) (*MPCSession, error) {
|
) (*MPCSession, error) {
|
||||||
sessionStatus, err := value_objects.NewSessionStatus(status)
|
sessionStatus, err := value_objects.NewSessionStatus(status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -418,5 +434,7 @@ func ReconstructSession(
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
CompletedAt: completedAt,
|
CompletedAt: completedAt,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
WalletName: walletName,
|
||||||
|
InviteCode: inviteCode,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -515,3 +515,60 @@ model SystemConfig {
|
||||||
@@index([key])
|
@@index([key])
|
||||||
@@map("system_configs")
|
@@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 { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -79,6 +89,8 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||||
UserTagController,
|
UserTagController,
|
||||||
ClassificationRuleController,
|
ClassificationRuleController,
|
||||||
AudienceSegmentController,
|
AudienceSegmentController,
|
||||||
|
// Co-Managed Wallet Controller
|
||||||
|
CoManagedWalletController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -134,6 +146,17 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||||
AudienceSegmentService,
|
AudienceSegmentService,
|
||||||
// Scheduled Jobs
|
// Scheduled Jobs
|
||||||
AutoTagSyncJob,
|
AutoTagSyncJob,
|
||||||
|
// Co-Managed Wallet
|
||||||
|
CoManagedWalletMapper,
|
||||||
|
CoManagedWalletService,
|
||||||
|
{
|
||||||
|
provide: CO_MANAGED_WALLET_SESSION_REPOSITORY,
|
||||||
|
useClass: CoManagedWalletSessionRepositoryImpl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CO_MANAGED_WALLET_REPOSITORY,
|
||||||
|
useClass: CoManagedWalletRepositoryImpl,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { useState } from 'react';
|
||||||
import { PageContainer } from '@/components/layout';
|
import { PageContainer } from '@/components/layout';
|
||||||
import { cn } from '@/utils/helpers';
|
import { cn } from '@/utils/helpers';
|
||||||
|
import { CoManagedWalletSection } from '@/components/features/co-managed-wallet';
|
||||||
import styles from './authorization.module.scss';
|
import styles from './authorization.module.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,6 +177,9 @@ export default function AuthorizationPage() {
|
||||||
return (
|
return (
|
||||||
<PageContainer title="授权管理">
|
<PageContainer title="授权管理">
|
||||||
<div className={styles.authorization}>
|
<div className={styles.authorization}>
|
||||||
|
{/* 共管钱包管理 */}
|
||||||
|
<CoManagedWalletSection />
|
||||||
|
|
||||||
{/* 授权省公司管理 */}
|
{/* 授权省公司管理 */}
|
||||||
<section className={styles.authorization__card}>
|
<section className={styles.authorization__card}>
|
||||||
<h3 className={styles.authorization__cardTitle}>授权省公司管理</h3>
|
<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}`,
|
DELETE: (id: string) => `/v1/admin/segments/${id}`,
|
||||||
REFRESH: (id: string) => `/v1/admin/segments/${id}/refresh`,
|
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;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue