diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1b2fcabf..638035f2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 \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 \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 \nEOF\n\\)\")", + "Bash(xargs ls:*)", + "Bash(tree:*)" ], "deny": [], "ask": [] diff --git a/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.down.sql b/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.down.sql new file mode 100644 index 00000000..a91d9b57 --- /dev/null +++ b/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.down.sql @@ -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')); diff --git a/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.up.sql b/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.up.sql new file mode 100644 index 00000000..3a526334 --- /dev/null +++ b/backend/mpc-system/migrations/008_add_co_managed_wallet_fields.up.sql @@ -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'; diff --git a/backend/mpc-system/services/service-party-app/electron-builder.json b/backend/mpc-system/services/service-party-app/electron-builder.json new file mode 100644 index 00000000..e55dbf18 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron-builder.json @@ -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 +} diff --git a/backend/mpc-system/services/service-party-app/electron/main.ts b/backend/mpc-system/services/service-party-app/electron/main.ts new file mode 100644 index 00000000..fe04785c --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron/main.ts @@ -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 | 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(); +}); diff --git a/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts b/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts new file mode 100644 index 00000000..15431d3e --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron/modules/grpc-client.ts @@ -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; + 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 | null = null; + private eventStream: grpc.ClientReadableStream | null = null; + + constructor() { + super(); + } + + /** + * 连接到 Message Router + */ + async connect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const proto = grpc.loadPackageDefinition(packageDefinition) as Record; + const MessageRouter = (proto.mpc?.router?.v1 as Record)?.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 { + 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 { + 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 }) + .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 }) + .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 { + 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 { + 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); + } + } + ); + }); + } +} diff --git a/backend/mpc-system/services/service-party-app/electron/modules/storage.ts b/backend/mpc-system/services/service-party-app/electron/modules/storage.ts new file mode 100644 index 00000000..2a82b579 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron/modules/storage.ts @@ -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; + + constructor() { + this.store = new Store({ + 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 & { 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[] { + 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): void { + const current = this.store.get('settings'); + this.store.set('settings', { ...current, ...settings }); + } +} diff --git a/backend/mpc-system/services/service-party-app/electron/modules/tss-handler.ts b/backend/mpc-system/services/service-party-app/electron/modules/tss-handler.ts new file mode 100644 index 00000000..d670c2c9 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron/modules/tss-handler.ts @@ -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 = 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/mpc-system/services/service-party-app/electron/preload.ts b/backend/mpc-system/services/service-party-app/electron/preload.ts new file mode 100644 index 00000000..12eb5103 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/electron/preload.ts @@ -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; + }; + } +} diff --git a/backend/mpc-system/services/service-party-app/index.html b/backend/mpc-system/services/service-party-app/index.html new file mode 100644 index 00000000..2ed764fc --- /dev/null +++ b/backend/mpc-system/services/service-party-app/index.html @@ -0,0 +1,15 @@ + + + + + + RWADurian Service Party + + + + + +
+ + + diff --git a/backend/mpc-system/services/service-party-app/package.json b/backend/mpc-system/services/service-party-app/package.json new file mode 100644 index 00000000..a00ad2eb --- /dev/null +++ b/backend/mpc-system/services/service-party-app/package.json @@ -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" + } + } +} diff --git a/backend/mpc-system/services/service-party-app/src/App.tsx b/backend/mpc-system/services/service-party-app/src/App.tsx new file mode 100644 index 00000000..b227c7f2 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/App.tsx @@ -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 ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/backend/mpc-system/services/service-party-app/src/components/Layout.module.css b/backend/mpc-system/services/service-party-app/src/components/Layout.module.css new file mode 100644 index 00000000..09e289f6 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/components/Layout.module.css @@ -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); +} diff --git a/backend/mpc-system/services/service-party-app/src/components/Layout.tsx b/backend/mpc-system/services/service-party-app/src/components/Layout.tsx new file mode 100644 index 00000000..5432d224 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/components/Layout.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/main.tsx b/backend/mpc-system/services/service-party-app/src/main.tsx new file mode 100644 index 00000000..43c906c9 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/main.tsx @@ -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( + + + + + +); diff --git a/backend/mpc-system/services/service-party-app/src/pages/Create.module.css b/backend/mpc-system/services/service-party-app/src/pages/Create.module.css new file mode 100644 index 00000000..b531ab17 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Create.module.css @@ -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); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Create.tsx b/backend/mpc-system/services/service-party-app/src/pages/Create.tsx new file mode 100644 index 00000000..deed1ba6 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Create.tsx @@ -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(null); + const [result, setResult] = useState(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 ( +
+
+

创建共管钱包

+

设置钱包参数并邀请其他参与方

+ + {step === 'config' && ( +
+
+ + setWalletName(e.target.value)} + placeholder="为您的共管钱包命名" + className={styles.input} + disabled={isLoading} + /> +
+ +
+ +
+
+ 签名阈值 (T) +
+ + {thresholdT} + +
+
+
of
+
+ 参与方总数 (N) +
+ + {thresholdN} + +
+
+
+

+ 需要 {thresholdT} 个参与方共同签名才能执行交易 +

+
+ +
+ + setParticipantName(e.target.value)} + placeholder="输入您的名称(其他参与者可见)" + className={styles.input} + disabled={isLoading} + /> +
+ + {error &&
{error}
} + +
+ + +
+
+ )} + + {step === 'creating' && ( +
+
+

正在创建会话...

+
+ )} + + {step === 'created' && result && ( +
+
+

会话创建成功

+ +
+ +
+ {result.inviteCode} + +
+

+ 将此邀请码分享给其他参与方,他们可以使用此码加入会话 +

+
+ +
+ +
+
+ )} +
+
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.module.css b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css new file mode 100644 index 00000000..0b208e5c --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.module.css @@ -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); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Home.tsx b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx new file mode 100644 index 00000000..ccfc2685 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Home.tsx @@ -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([]); + 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 ( +
+
+

加载中...

+
+ ); + } + + return ( +
+
+

我的共管钱包

+

管理您参与的多方共管钱包

+
+ + {shares.length === 0 ? ( +
+
🔐
+

暂无共管钱包

+

+ 您可以创建新的共管钱包,或加入他人发起的钱包创建 +

+
+ + +
+
+ ) : ( +
+ {shares.map((share) => ( +
+
+

{share.walletName}

+ + {share.threshold.t}-of-{share.threshold.n} + +
+
+
+ 公钥 + + {truncateKey(share.publicKey)} + +
+
+ 参与方 + + {share.metadata.participants.length} 人 + +
+
+ 创建时间 + + {formatDate(share.createdAt)} + +
+ {share.lastUsedAt && ( +
+ 上次使用 + + {formatDate(share.lastUsedAt)} + +
+ )} +
+
+ +
+
+ ))} +
+ )} +
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Join.module.css b/backend/mpc-system/services/service-party-app/src/pages/Join.module.css new file mode 100644 index 00000000..a44cc166 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Join.module.css @@ -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); + } +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Join.tsx b/backend/mpc-system/services/service-party-app/src/pages/Join.tsx new file mode 100644 index 00000000..e057de21 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Join.tsx @@ -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(null); + const [sessionInfo, setSessionInfo] = useState(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 ( +
+
+

加入共管钱包

+

输入邀请码或扫描二维码加入

+ + {step === 'input' && ( +
+
+ +
+ setCode(e.target.value)} + placeholder="粘贴邀请码或邀请链接" + className={styles.input} + disabled={isLoading} + /> + +
+
+ + {error &&
{error}
} + +
+ + +
+
+ )} + + {step === 'confirm' && sessionInfo && ( +
+
+

会话信息

+
+
+ 钱包名称 + {sessionInfo.walletName} +
+
+ 阈值设置 + + {sessionInfo.threshold.t}-of-{sessionInfo.threshold.n} + +
+
+ 发起者 + {sessionInfo.initiator} +
+
+ 当前参与者 + + {sessionInfo.currentParticipants} / {sessionInfo.threshold.n} + +
+
+
+ +
+ + setParticipantName(e.target.value)} + placeholder="输入您的名称(其他参与者可见)" + className={styles.input} + disabled={isLoading} + /> +
+ + {error &&
{error}
} + +
+ + +
+
+ )} + + {step === 'joining' && ( +
+
+

正在加入会话...

+
+ )} +
+
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Session.module.css b/backend/mpc-system/services/service-party-app/src/pages/Session.module.css new file mode 100644 index 00000000..842e3e61 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Session.module.css @@ -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); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Session.tsx b/backend/mpc-system/services/service-party-app/src/pages/Session.tsx new file mode 100644 index 00000000..02123113 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Session.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

加载会话信息...

+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
!
+

加载失败

+

{error || '无法获取会话信息'}

+ +
+
+ ); + } + + return ( +
+
+
+
+

{session.walletName}

+

会话 ID: {session.sessionId}

+
+ + {getStatusText(session.status)} + +
+ +
+ {/* 进度部分 */} + {session.status === 'processing' && ( +
+
+ 密钥生成进度 + {session.currentRound} / {session.totalRounds} +
+
+
+
+
+ )} + + {/* 参与方列表 */} +
+

+ 参与方 ({session.participants.length} / {session.threshold.n}) +

+
+ {session.participants.map((participant, index) => ( +
+
+ #{index + 1} + {participant.name} +
+ + {getParticipantStatusIcon(participant.status)} + +
+ ))} + {Array.from({ length: session.threshold.n - session.participants.length }).map((_, index) => ( +
+
+ #{session.participants.length + index + 1} + 等待加入... +
+ +
+ ))} +
+
+ + {/* 阈值信息 */} +
+

阈值设置

+
+ + {session.threshold.t}-of-{session.threshold.n} + + + 需要 {session.threshold.t} 个参与方共同签名 + +
+
+ + {/* 完成状态 */} + {session.status === 'completed' && session.publicKey && ( +
+

钱包公钥

+
+ {session.publicKey} + +
+

+ ✓ 密钥份额已安全保存到本地 +

+
+ )} + + {/* 失败状态 */} + {session.status === 'failed' && session.error && ( +
+
+ ! + {session.error} +
+
+ )} +
+ +
+ {session.status === 'completed' ? ( + + ) : session.status === 'failed' ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Settings.module.css b/backend/mpc-system/services/service-party-app/src/pages/Settings.module.css new file mode 100644 index 00000000..37907030 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Settings.module.css @@ -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); + } +} diff --git a/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx new file mode 100644 index 00000000..eef92f66 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/pages/Settings.tsx @@ -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({ + 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 ( +
+
+
+

加载设置...

+
+
+ ); + } + + return ( +
+
+

设置

+
+ +
+ {/* 连接设置 */} +
+

连接设置

+
+
+ +
+ setSettings(prev => ({ ...prev, messageRouterUrl: e.target.value }))} + placeholder="localhost:50051" + className={styles.input} + /> + +
+

+ 输入 Message Router 服务的 gRPC 地址 +

+
+
+
+ + {/* 备份设置 */} +
+

备份设置

+
+
+
+ setSettings(prev => ({ ...prev, autoBackup: e.target.checked }))} + className={styles.checkbox} + /> + +
+

+ 创建新的密钥份额后自动备份到指定目录 +

+
+ + {settings.autoBackup && ( +
+ +
+ setSettings(prev => ({ ...prev, backupPath: e.target.value }))} + placeholder="选择备份目录" + className={styles.input} + readOnly + /> + +
+
+ )} +
+
+ + {/* 数据管理 */} +
+

数据管理

+
+
+ +

+ 将所有密钥份额导出为加密文件 +

+ +
+ +
+ +
+ +

+ 从加密备份文件导入密钥份额 +

+ +
+ +
+ +
+ +

+ 删除所有本地存储的密钥份额。此操作不可恢复! +

+ +
+
+
+ + {/* 关于 */} +
+

关于

+
+
+
+ 应用名称 + Service Party +
+
+ 版本 + 1.0.0 +
+
+ 项目 + RWADurian MPC System +
+
+
+
+ + {message && ( +
+ {message.text} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/backend/mpc-system/services/service-party-app/src/styles/global.css b/backend/mpc-system/services/service-party-app/src/styles/global.css new file mode 100644 index 00000000..d093d250 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/styles/global.css @@ -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; +} diff --git a/backend/mpc-system/services/service-party-app/src/types/electron.d.ts b/backend/mpc-system/services/service-party-app/src/types/electron.d.ts new file mode 100644 index 00000000..ee122c8f --- /dev/null +++ b/backend/mpc-system/services/service-party-app/src/types/electron.d.ts @@ -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; + joinSession: (sessionId: string, participantName: string) => Promise; + validateInviteCode: (code: string) => Promise; + getSessionStatus: (sessionId: string) => Promise; + subscribeSessionEvents: (sessionId: string, callback: (event: SessionEvent) => void) => () => void; + testConnection: (url: string) => Promise; + }; + storage: { + listShares: () => Promise; + getShare: (id: string, password: string) => Promise; + 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; + saveSettings: (settings: Settings) => Promise; + }; + dialog: { + selectDirectory: () => Promise; + selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise; + saveFile: (defaultPath?: string, filters?: { name: string; extensions: string[] }[]) => Promise; + }; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} + +export {}; diff --git a/backend/mpc-system/services/service-party-app/tsconfig.json b/backend/mpc-system/services/service-party-app/tsconfig.json new file mode 100644 index 00000000..94c2410f --- /dev/null +++ b/backend/mpc-system/services/service-party-app/tsconfig.json @@ -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" }] +} diff --git a/backend/mpc-system/services/service-party-app/tsconfig.node.json b/backend/mpc-system/services/service-party-app/tsconfig.node.json new file mode 100644 index 00000000..7aad5dde --- /dev/null +++ b/backend/mpc-system/services/service-party-app/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "electron/**/*"] +} diff --git a/backend/mpc-system/services/service-party-app/tss-party/build.sh b/backend/mpc-system/services/service-party-app/tss-party/build.sh new file mode 100644 index 00000000..d0132ea4 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/tss-party/build.sh @@ -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" diff --git a/backend/mpc-system/services/service-party-app/tss-party/go.mod b/backend/mpc-system/services/service-party-app/tss-party/go.mod new file mode 100644 index 00000000..f07305cd --- /dev/null +++ b/backend/mpc-system/services/service-party-app/tss-party/go.mod @@ -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 +) diff --git a/backend/mpc-system/services/service-party-app/tss-party/main.go b/backend/mpc-system/services/service-party-app/tss-party/main.go new file mode 100644 index 00000000..cf6aa188 --- /dev/null +++ b/backend/mpc-system/services/service-party-app/tss-party/main.go @@ -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 [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)) +} diff --git a/backend/mpc-system/services/service-party-app/vite.config.ts b/backend/mpc-system/services/service-party-app/vite.config.ts new file mode 100644 index 00000000..041fd59f --- /dev/null +++ b/backend/mpc-system/services/service-party-app/vite.config.ts @@ -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, + }, +}); diff --git a/backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go b/backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go index 4323418b..b06148b9 100644 --- a/backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go +++ b/backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go @@ -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 { diff --git a/backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go b/backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go index 672bd133..37ef09c1 100644 --- a/backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go +++ b/backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go @@ -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) ) } diff --git a/backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go b/backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go index b2815c75..47ec9ec8 100644 --- a/backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go +++ b/backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go @@ -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 } diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index f27a32d6..e98908d5 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -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") +} diff --git a/backend/services/admin-service/src/api/controllers/co-managed-wallet.controller.ts b/backend/services/admin-service/src/api/controllers/co-managed-wallet.controller.ts new file mode 100644 index 00000000..fda2a3ec --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/co-managed-wallet.controller.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.walletService.cancelSession(sessionId); + } + + /** + * 获取钱包列表 + */ + @Get() + @ApiOperation({ summary: '获取共管钱包列表' }) + @ApiResponse({ status: 200, type: WalletListResponseDto }) + async listWallets(@Query() dto: ListWalletsDto): Promise { + 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 { + const wallet = await this.walletService.getWallet(walletId); + return CoManagedWalletDto.fromEntity(wallet); + } +} diff --git a/backend/services/admin-service/src/api/dto/request/co-managed-wallet.dto.ts b/backend/services/admin-service/src/api/dto/request/co-managed-wallet.dto.ts new file mode 100644 index 00000000..e193f5d6 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/co-managed-wallet.dto.ts @@ -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; +} diff --git a/backend/services/admin-service/src/api/dto/response/co-managed-wallet.dto.ts b/backend/services/admin-service/src/api/dto/response/co-managed-wallet.dto.ts new file mode 100644 index 00000000..f180ae42 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/response/co-managed-wallet.dto.ts @@ -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; +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 4f73273a..24aa8629 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/admin-service/src/application/services/co-managed-wallet.service.ts b/backend/services/admin-service/src/application/services/co-managed-wallet.service.ts new file mode 100644 index 00000000..1299bead --- /dev/null +++ b/backend/services/admin-service/src/application/services/co-managed-wallet.service.ts @@ -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 { + // 验证阈值 + 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 { + 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 { + 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 { + 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 { + 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 { + const session = await this.getSession(sessionId); + session.fail(error); + return this.sessionRepository.save(session); + } + + /** + * 取消会话 + */ + async cancelSession(sessionId: string): Promise { + 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 { + 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, + }; + } +} diff --git a/backend/services/admin-service/src/domain/entities/co-managed-wallet.entity.ts b/backend/services/admin-service/src/domain/entities/co-managed-wallet.entity.ts new file mode 100644 index 00000000..9254d531 --- /dev/null +++ b/backend/services/admin-service/src/domain/entities/co-managed-wallet.entity.ts @@ -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; + } +} diff --git a/backend/services/admin-service/src/domain/enums/wallet-session-status.enum.ts b/backend/services/admin-service/src/domain/enums/wallet-session-status.enum.ts new file mode 100644 index 00000000..2d87828d --- /dev/null +++ b/backend/services/admin-service/src/domain/enums/wallet-session-status.enum.ts @@ -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', +} diff --git a/backend/services/admin-service/src/domain/repositories/co-managed-wallet.repository.ts b/backend/services/admin-service/src/domain/repositories/co-managed-wallet.repository.ts new file mode 100644 index 00000000..e490262b --- /dev/null +++ b/backend/services/admin-service/src/domain/repositories/co-managed-wallet.repository.ts @@ -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; + + /** + * 根据 ID 查找会话 + */ + findById(id: string): Promise; + + /** + * 根据邀请码查找会话 + */ + findByInviteCode(inviteCode: string): Promise; + + /** + * 查找用户创建的会话列表 + */ + findByCreatedBy( + createdBy: string, + status?: WalletSessionStatus, + ): Promise; + + /** + * 查找所有会话 (分页) + */ + findAll(params: { + page: number; + pageSize: number; + status?: WalletSessionStatus; + }): Promise<{ + items: CoManagedWalletSessionEntity[]; + total: number; + }>; + + /** + * 删除会话 + */ + delete(id: string): Promise; +} + +/** + * 共管钱包仓储接口 + */ +export interface CoManagedWalletRepository { + /** + * 保存钱包 + */ + save(entity: CoManagedWalletEntity): Promise; + + /** + * 根据 ID 查找钱包 + */ + findById(id: string): Promise; + + /** + * 根据会话 ID 查找钱包 + */ + findBySessionId(sessionId: string): Promise; + + /** + * 查找用户创建的钱包列表 + */ + findByCreatedBy(createdBy: string): Promise; + + /** + * 查找所有钱包 (分页) + */ + findAll(params: { + page: number; + pageSize: number; + }): Promise<{ + items: CoManagedWalletEntity[]; + total: number; + }>; + + /** + * 删除钱包 + */ + delete(id: string): Promise; +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/mappers/co-managed-wallet.mapper.ts b/backend/services/admin-service/src/infrastructure/persistence/mappers/co-managed-wallet.mapper.ts new file mode 100644 index 00000000..370aa5bb --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/mappers/co-managed-wallet.mapper.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet-session.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet-session.repository.impl.ts new file mode 100644 index 00000000..f62e021f --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet-session.repository.impl.ts @@ -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 { + 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 { + const session = await this.prisma.coManagedWalletSession.findUnique({ + where: { id }, + }); + + if (!session) { + return null; + } + + return this.mapper.sessionToDomain(session); + } + + async findByInviteCode(inviteCode: string): Promise { + 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 { + 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 { + await this.prisma.coManagedWalletSession.delete({ + where: { id }, + }); + } +} diff --git a/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet.repository.impl.ts b/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet.repository.impl.ts new file mode 100644 index 00000000..1f3b8ed6 --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/persistence/repositories/co-managed-wallet.repository.impl.ts @@ -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 { + 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 { + const wallet = await this.prisma.coManagedWallet.findUnique({ + where: { id }, + }); + + if (!wallet) { + return null; + } + + return this.mapper.walletToDomain(wallet); + } + + async findBySessionId(sessionId: string): Promise { + const wallet = await this.prisma.coManagedWallet.findUnique({ + where: { sessionId }, + }); + + if (!wallet) { + return null; + } + + return this.mapper.walletToDomain(wallet); + } + + async findByCreatedBy(createdBy: string): Promise { + 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 { + await this.prisma.coManagedWallet.delete({ + where: { id }, + }); + } +} diff --git a/docs/co-managed-wallet-implementation-plan.md b/docs/co-managed-wallet-implementation-plan.md new file mode 100644 index 00000000..29b77c4c --- /dev/null +++ b/docs/co-managed-wallet-implementation-plan.md @@ -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; + + // 获取会话状态 + getSessionStatus(sessionId: string): Promise; + + // 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; + + // 参与 signing (未来扩展) + async participateSigning(sessionInfo: SessionInfo, messageHash: string): Promise; +} +``` + +#### 本地加密存储 + +**文件**: `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天** | diff --git a/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx b/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx index 274a47db..07630aa7 100644 --- a/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/authorization/page.tsx @@ -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 (
+ {/* 共管钱包管理 */} + + {/* 授权省公司管理 */}

授权省公司管理

diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/CoManagedWalletSection.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/CoManagedWalletSection.tsx new file mode 100644 index 00000000..f579ae0b --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/CoManagedWalletSection.tsx @@ -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(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 ( +
+
+
+

共管钱包管理

+

+ 创建和管理分布式多方共管钱包,支持多人协同签名 +

+
+ +
+ + {wallets.length === 0 ? ( +
+
🔐
+

+ 暂无共管钱包 +

+

+ 创建共管钱包后,多个参与方可以共同管理资产 +

+ +
+ ) : ( +
+ {wallets.map(wallet => ( +
+
+

{wallet.name}

+ + {wallet.threshold.t}-of-{wallet.threshold.n} + +
+
+
+ 公钥 + + {truncateKey(wallet.publicKey)} + +
+
+ 参与方 + + {wallet.participantCount} 人 + +
+
+ 创建时间 + + {formatDate(wallet.createdAt)} + +
+
+
+ +
+
+ ))} +
+ )} + +

+ 帮助:共管钱包使用 MPC (多方计算) 技术,无需暴露完整私钥即可进行安全签名。 + 每个参与方持有密钥份额,需要达到阈值数量的参与方才能完成签名。 +

+ + setIsModalOpen(false)} + /> +
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/CreateWalletModal.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/CreateWalletModal.tsx new file mode 100644 index 00000000..b5b5d4f8 --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/CreateWalletModal.tsx @@ -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('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(null); + const [session, setSession] = useState(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 ( +
+ {steps.map((s, index) => ( +
+
{index + 1}
+ {s.label} +
+ ))} +
+ ); + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

创建共管钱包

+ +
+ + {renderStepIndicator()} + +
+ {step === 'config' && ( +
+
+ + setWalletName(e.target.value)} + placeholder="为您的共管钱包命名" + className={styles.createWalletModal__input} + disabled={isLoading} + /> +
+ +
+ + +

+ 需要 {thresholdT} 个参与方共同签名才能执行交易 +

+
+ +
+ + setInitiatorName(e.target.value)} + placeholder="输入您的名称(其他参与者可见)" + className={styles.createWalletModal__input} + disabled={isLoading} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+ )} + + {step === 'invite' && session && ( +
+ + + + +
+ + +
+
+ )} + + {step === 'progress' && session && ( +
+ + + +
+ )} + + {step === 'result' && session && session.publicKey && ( + + )} +
+
+
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/InviteQRCode.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/InviteQRCode.tsx new file mode 100644 index 00000000..2eef02bc --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/InviteQRCode.tsx @@ -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(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 ( +
+
+ {qrDataUrl ? ( + 邀请二维码 + ) : ( +
+ 生成中... +
+ )} +
+ +
+
+ +
+ {inviteCode} + +
+
+ +
+ +
+ + +
+
+ +

+ 将邀请码或链接分享给其他参与方,他们可以使用 Service Party 应用加入 +

+
+
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/ParticipantList.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/ParticipantList.tsx new file mode 100644 index 00000000..d0eb27e2 --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/ParticipantList.tsx @@ -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 ( +
+
+ + 参与方 ({participants.length} / {totalRequired}) + +
+ +
+ {participants.map((participant, index) => ( +
+
+ #{index + 1} + {participant.name} +
+ + + {getStatusIcon(participant.status)} + + {getStatusText(participant.status)} + +
+ ))} + + {Array.from({ length: emptySlots }).map((_, index) => ( +
+
+ + #{participants.length + index + 1} + + 等待加入... +
+ +
+ ))} +
+
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/SessionProgress.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/SessionProgress.tsx new file mode 100644 index 00000000..a3560bf4 --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/SessionProgress.tsx @@ -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 ( +
+
+ + {getStatusText()} + + {status === 'processing' && ( + + 第 {currentRound} / {totalRounds} 轮 + + )} +
+ +
+
+
+ + {status === 'processing' && ( +

+ 正在与其他参与方进行安全的密钥生成协议,请勿关闭页面 +

+ )} + + {status === 'failed' && error && ( +

{error}

+ )} +
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/ThresholdConfig.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/ThresholdConfig.tsx new file mode 100644 index 00000000..6473265c --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/ThresholdConfig.tsx @@ -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 ( +
+
+ 签名阈值 (T) +
+ + {thresholdT} + +
+
+ +
of
+ +
+ 参与方总数 (N) +
+ + {thresholdN} + +
+
+
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/WalletResult.tsx b/frontend/admin-web/src/components/features/co-managed-wallet/WalletResult.tsx new file mode 100644 index 00000000..feab9338 --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/WalletResult.tsx @@ -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 ( +
+
+

共管钱包创建成功

+ +
+
+ 钱包名称 + {walletName} +
+ +
+ 阈值设置 + + {threshold.t}-of-{threshold.n} + +
+ +
+ 创建时间 + {formatDate(createdAt)} +
+ +
+ 参与方 + + {participants.map(p => p.name).join('、')} + +
+
+ +
+ +
+ {publicKey} + +
+
+ +
+ 重要提示: +
    +
  • 每个参与方的密钥份额已保存在各自的 Service Party 应用中
  • +
  • 请确保所有参与方都已备份各自的密钥份额
  • +
  • 签名交易时需要至少 {threshold.t} 个参与方共同参与
  • +
+
+ +
+ +
+
+ ); +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/co-managed-wallet.module.scss b/frontend/admin-web/src/components/features/co-managed-wallet/co-managed-wallet.module.scss new file mode 100644 index 00000000..eaf313eb --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/co-managed-wallet.module.scss @@ -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%); + } + } +} diff --git a/frontend/admin-web/src/components/features/co-managed-wallet/index.ts b/frontend/admin-web/src/components/features/co-managed-wallet/index.ts new file mode 100644 index 00000000..cac358e5 --- /dev/null +++ b/frontend/admin-web/src/components/features/co-managed-wallet/index.ts @@ -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'; diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index 2f572983..8212c16f 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -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;