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:
hailin 2025-12-27 21:39:07 -08:00
parent 9c7dc6f511
commit fea01642e7
60 changed files with 9171 additions and 15 deletions

View File

@ -440,7 +440,9 @@
"Bash(npx jest --testPathPattern=\"referral\" --passWithNoTests)",
"Bash(npx jest --testPathPattern=\"wallet\" --passWithNoTests)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nrefactor\\(mobile-app\\): 修改\"我的\"页面文案\n\n- \"个人种植树\" → \"本人种植树\"\n- 引荐列表中 \"个人/团队\" → \"本人/同僚\"\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(identity-service\\): 增强钱包生成可靠性确保100%生成成功\n\n核心改进\n- 基于数据库扫描代替Redis扫描防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(identity-service\\): 增强钱包生成可靠性确保100%生成成功\n\n核心改进\n- 基于数据库扫描代替Redis扫描防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(xargs ls:*)",
"Bash(tree:*)"
],
"deny": [],
"ask": []

View File

@ -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'));

View File

@ -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';

View File

@ -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
}

View File

@ -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();
});

View File

@ -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);
}
}
);
});
}
}

View File

@ -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 });
}
}

View File

@ -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;
}
}

View File

@ -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;
};
}
}

View File

@ -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>

View File

@ -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"
}
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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);
}
}

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -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>
);
}

View File

@ -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);
}
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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 {};

View File

@ -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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "electron/**/*"]
}

View File

@ -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"

View File

@ -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
)

View File

@ -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))
}

View File

@ -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,
},
});

View File

@ -37,8 +37,9 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
_, err = tx.ExecContext(ctx, `
INSERT INTO mpc_sessions (
id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
wallet_name, invite_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
message_hash = EXCLUDED.message_hash,
@ -47,7 +48,9 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
keygen_session_id = EXCLUDED.keygen_session_id,
updated_at = EXCLUDED.updated_at,
completed_at = EXCLUDED.completed_at,
version = EXCLUDED.version
version = EXCLUDED.version,
wallet_name = EXCLUDED.wallet_name,
invite_code = EXCLUDED.invite_code
`,
session.ID.UUID(),
string(session.SessionType),
@ -64,6 +67,8 @@ func (r *SessionPostgresRepo) Save(ctx context.Context, session *entities.MPCSes
session.ExpiresAt,
session.CompletedAt,
session.Version,
session.WalletName,
session.InviteCode,
)
if err != nil {
return err
@ -120,7 +125,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
var session sessionRow
err := r.db.QueryRowContext(ctx, `
SELECT id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
FROM mpc_sessions WHERE id = $1
`, id).Scan(
&session.ID,
@ -138,6 +144,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
&session.ExpiresAt,
&session.CompletedAt,
&session.Version,
&session.WalletName,
&session.InviteCode,
)
if err != nil {
if err == sql.ErrNoRows {
@ -175,6 +183,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
session.CompletedAt,
participants,
session.Version,
session.WalletName,
session.InviteCode,
)
}
@ -182,7 +192,8 @@ func (r *SessionPostgresRepo) FindByUUID(ctx context.Context, id uuid.UUID) (*en
func (r *SessionPostgresRepo) FindByStatus(ctx context.Context, status value_objects.SessionStatus) ([]*entities.MPCSession, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
FROM mpc_sessions WHERE status = $1
`, status.String())
if err != nil {
@ -197,7 +208,8 @@ func (r *SessionPostgresRepo) FindByStatus(ctx context.Context, status value_obj
func (r *SessionPostgresRepo) FindExpired(ctx context.Context) ([]*entities.MPCSession, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
FROM mpc_sessions
WHERE expires_at < NOW() AND status IN ('created', 'in_progress')
`)
@ -213,7 +225,8 @@ func (r *SessionPostgresRepo) FindExpired(ctx context.Context) ([]*entities.MPCS
func (r *SessionPostgresRepo) FindActive(ctx context.Context) ([]*entities.MPCSession, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
FROM mpc_sessions
WHERE status IN ('created', 'in_progress')
ORDER BY created_at ASC
@ -230,7 +243,8 @@ func (r *SessionPostgresRepo) FindActive(ctx context.Context) ([]*entities.MPCSe
func (r *SessionPostgresRepo) FindByCreator(ctx context.Context, creatorID string) ([]*entities.MPCSession, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_type, threshold_n, threshold_t, status,
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version
message_hash, public_key, delegate_party_id, keygen_session_id, created_by, created_at, updated_at, expires_at, completed_at, version,
COALESCE(wallet_name, ''), COALESCE(invite_code, '')
FROM mpc_sessions WHERE created_by = $1
ORDER BY created_at DESC
`, creatorID)
@ -246,7 +260,8 @@ func (r *SessionPostgresRepo) FindByCreator(ctx context.Context, creatorID strin
func (r *SessionPostgresRepo) FindActiveByParticipant(ctx context.Context, partyID value_objects.PartyID) ([]*entities.MPCSession, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT s.id, s.session_type, s.threshold_n, s.threshold_t, s.status,
s.message_hash, s.public_key, s.delegate_party_id, s.keygen_session_id, s.created_by, s.created_at, s.updated_at, s.expires_at, s.completed_at, s.version
s.message_hash, s.public_key, s.delegate_party_id, s.keygen_session_id, s.created_by, s.created_at, s.updated_at, s.expires_at, s.completed_at, s.version,
COALESCE(s.wallet_name, ''), COALESCE(s.invite_code, '')
FROM mpc_sessions s
JOIN participants p ON s.id = p.session_id
WHERE p.party_id = $1 AND s.status IN ('created', 'in_progress')
@ -504,6 +519,8 @@ func (r *SessionPostgresRepo) scanSessions(ctx context.Context, rows *sql.Rows)
&s.ExpiresAt,
&s.CompletedAt,
&s.Version,
&s.WalletName,
&s.InviteCode,
)
if err != nil {
return nil, err
@ -537,6 +554,8 @@ func (r *SessionPostgresRepo) scanSessions(ctx context.Context, rows *sql.Rows)
s.CompletedAt,
participants,
s.Version,
s.WalletName,
s.InviteCode,
)
if err != nil {
return nil, err
@ -564,6 +583,8 @@ type sessionRow struct {
ExpiresAt time.Time
CompletedAt *time.Time
Version int64
WalletName string // 钱包名称 (for co_managed_keygen)
InviteCode string // 邀请码 (for co_managed_keygen)
}
type participantRow struct {

View File

@ -159,6 +159,8 @@ type sessionCacheEntry struct {
ExpiresAt int64 `json:"expires_at"`
CompletedAt *int64 `json:"completed_at,omitempty"`
Participants []participantCacheEntry `json:"participants"`
WalletName string `json:"wallet_name,omitempty"` // 钱包名称 (for co_managed_keygen)
InviteCode string `json:"invite_code,omitempty"` // 邀请码 (for co_managed_keygen)
}
type participantCacheEntry struct {
@ -214,6 +216,8 @@ func sessionToCacheEntry(s *entities.MPCSession) sessionCacheEntry {
ExpiresAt: s.ExpiresAt.UnixMilli(),
CompletedAt: completedAt,
Participants: participants,
WalletName: s.WalletName,
InviteCode: s.InviteCode,
}
}
@ -265,14 +269,17 @@ func cacheEntryToSession(entry sessionCacheEntry) (*entities.MPCSession, error)
entry.Status,
entry.MessageHash,
entry.PublicKey,
"", // delegatePartyID - not cached
"", // delegatePartyID - not cached
uuid.Nil, // keygenSessionID - not cached
entry.CreatedBy,
time.UnixMilli(entry.CreatedAt),
time.UnixMilli(entry.UpdatedAt),
time.UnixMilli(entry.ExpiresAt),
completedAt,
participants,
1, // version - default to 1 for cached sessions (not used in cache)
1, // version - default to 1 for cached sessions (not used in cache)
entry.WalletName, // 钱包名称 (for co_managed_keygen)
entry.InviteCode, // 邀请码 (for co_managed_keygen)
)
}

View File

@ -25,13 +25,19 @@ var (
type SessionType string
const (
SessionTypeKeygen SessionType = "keygen"
SessionTypeSign SessionType = "sign"
SessionTypeKeygen SessionType = "keygen"
SessionTypeSign SessionType = "sign"
SessionTypeCoManagedKeygen SessionType = "co_managed_keygen" // 共管钱包密钥生成
)
// IsValid checks if the session type is valid
func (t SessionType) IsValid() bool {
return t == SessionTypeKeygen || t == SessionTypeSign
return t == SessionTypeKeygen || t == SessionTypeSign || t == SessionTypeCoManagedKeygen
}
// IsKeygen checks if the session type is a keygen type (includes co_managed_keygen)
func (t SessionType) IsKeygen() bool {
return t == SessionTypeKeygen || t == SessionTypeCoManagedKeygen
}
// MPCSession represents an MPC session
@ -52,6 +58,10 @@ type MPCSession struct {
ExpiresAt time.Time
CompletedAt *time.Time
Version int64 // Optimistic locking version number
// Co-managed wallet specific fields (共管钱包特有字段)
WalletName string // 钱包名称 (for co_managed_keygen)
InviteCode string // 邀请码 (for co_managed_keygen)
}
// NewMPCSession creates a new MPC session
@ -354,6 +364,8 @@ func (s *MPCSession) ToDTO() SessionDTO {
Status: s.Status.String(),
CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt,
WalletName: s.WalletName,
InviteCode: s.InviteCode,
}
}
@ -367,6 +379,8 @@ type SessionDTO struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
WalletName string `json:"wallet_name,omitempty"` // 钱包名称 (for co_managed_keygen)
InviteCode string `json:"invite_code,omitempty"` // 邀请码 (for co_managed_keygen)
}
// ParticipantDTO is a data transfer object for participants
@ -391,6 +405,8 @@ func ReconstructSession(
completedAt *time.Time,
participants []*Participant,
version int64,
walletName string, // 钱包名称 (for co_managed_keygen)
inviteCode string, // 邀请码 (for co_managed_keygen)
) (*MPCSession, error) {
sessionStatus, err := value_objects.NewSessionStatus(status)
if err != nil {
@ -418,5 +434,7 @@ func ReconstructSession(
ExpiresAt: expiresAt,
CompletedAt: completedAt,
Version: version,
WalletName: walletName,
InviteCode: inviteCode,
}, nil
}

View File

@ -515,3 +515,60 @@ model SystemConfig {
@@index([key])
@@map("system_configs")
}
// =============================================================================
// Co-Managed Wallet System (共管钱包系统)
// =============================================================================
/// 共管钱包会话状态
enum WalletSessionStatus {
WAITING // 等待参与方加入
READY // 所有参与方已就绪
PROCESSING // 密钥生成中
COMPLETED // 创建完成
FAILED // 创建失败
CANCELLED // 已取消
}
/// 共管钱包会话 - 钱包创建过程的会话记录
model CoManagedWalletSession {
id String @id @default(uuid())
walletName String @map("wallet_name") @db.VarChar(100) // 钱包名称
thresholdT Int @map("threshold_t") // 签名阈值 T
thresholdN Int @map("threshold_n") // 参与方总数 N
inviteCode String @unique @map("invite_code") @db.VarChar(20) // 邀请码
status WalletSessionStatus @default(WAITING) // 会话状态
participants String @db.Text // 参与方列表 (JSON)
currentRound Int @default(0) @map("current_round") // 当前密钥生成轮次
totalRounds Int @default(3) @map("total_rounds") // 总轮次
publicKey String? @map("public_key") @db.VarChar(200) // 生成的公钥
error String? @db.Text // 错误信息
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String @map("created_by") @db.VarChar(100) // 创建者
@@index([inviteCode])
@@index([status])
@@index([createdBy])
@@index([createdAt])
@@map("co_managed_wallet_sessions")
}
/// 共管钱包 - 创建成功后的钱包记录
model CoManagedWallet {
id String @id @default(uuid())
sessionId String @unique @map("session_id") // 关联的会话 ID
name String @db.VarChar(100) // 钱包名称
publicKey String @map("public_key") @db.VarChar(200) // 钱包公钥
thresholdT Int @map("threshold_t") // 签名阈值 T
thresholdN Int @map("threshold_n") // 参与方总数 N
participants String @db.Text // 参与方列表 (JSON)
createdAt DateTime @default(now()) @map("created_at")
createdBy String @map("created_by") @db.VarChar(100) // 创建者
@@index([sessionId])
@@index([publicKey])
@@index([createdBy])
@@index([createdAt])
@@map("co_managed_wallets")
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -50,6 +50,16 @@ import { UserTagController } from './api/controllers/user-tag.controller';
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
// Co-Managed Wallet imports
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
import { CoManagedWalletMapper } from './infrastructure/persistence/mappers/co-managed-wallet.mapper';
import { CoManagedWalletSessionRepositoryImpl } from './infrastructure/persistence/repositories/co-managed-wallet-session.repository.impl';
import { CoManagedWalletRepositoryImpl } from './infrastructure/persistence/repositories/co-managed-wallet.repository.impl';
import {
CO_MANAGED_WALLET_SESSION_REPOSITORY,
CO_MANAGED_WALLET_REPOSITORY,
} from './domain/repositories/co-managed-wallet.repository';
@Module({
imports: [
@ -79,6 +89,8 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
UserTagController,
ClassificationRuleController,
AudienceSegmentController,
// Co-Managed Wallet Controller
CoManagedWalletController,
],
providers: [
PrismaService,
@ -134,6 +146,17 @@ import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
AudienceSegmentService,
// Scheduled Jobs
AutoTagSyncJob,
// Co-Managed Wallet
CoManagedWalletMapper,
CoManagedWalletService,
{
provide: CO_MANAGED_WALLET_SESSION_REPOSITORY,
useClass: CoManagedWalletSessionRepositoryImpl,
},
{
provide: CO_MANAGED_WALLET_REPOSITORY,
useClass: CoManagedWalletRepositoryImpl,
},
],
})
export class AppModule {}

View File

@ -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,
};
}
}

View File

@ -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;
}
}

View File

@ -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',
}

View File

@ -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>;
}

View File

@ -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,
};
}
}

View File

@ -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 },
});
}
}

View File

@ -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 },
});
}
}

View File

@ -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天** |

View File

@ -3,6 +3,7 @@
import { useState } from 'react';
import { PageContainer } from '@/components/layout';
import { cn } from '@/utils/helpers';
import { CoManagedWalletSection } from '@/components/features/co-managed-wallet';
import styles from './authorization.module.scss';
/**
@ -176,6 +177,9 @@ export default function AuthorizationPage() {
return (
<PageContainer title="授权管理">
<div className={styles.authorization}>
{/* 共管钱包管理 */}
<CoManagedWalletSection />
{/* 授权省公司管理 */}
<section className={styles.authorization__card}>
<h3 className={styles.authorization__cardTitle}></h3>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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%);
}
}
}

View File

@ -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';

View File

@ -145,4 +145,12 @@ export const API_ENDPOINTS = {
DELETE: (id: string) => `/v1/admin/segments/${id}`,
REFRESH: (id: string) => `/v1/admin/segments/${id}/refresh`,
},
// 共管钱包 (admin-service)
CO_MANAGED_WALLETS: {
LIST: '/v1/admin/co-managed-wallets',
CREATE_SESSION: '/v1/admin/co-managed-wallets/sessions',
SESSION_DETAIL: (sessionId: string) => `/v1/admin/co-managed-wallets/sessions/${sessionId}`,
WALLET_DETAIL: (walletId: string) => `/v1/admin/co-managed-wallets/${walletId}`,
},
} as const;