feat(co-managed-wallet): 添加签名会话API和Service-Party-App HTTP客户端

## Account Service 新增 API
- GET /api/v1/co-managed/sessions/by-invite-code/:inviteCode - 通过邀请码查询keygen会话
- POST /api/v1/co-managed/sign - 创建签名会话
- GET /api/v1/co-managed/sign/by-invite-code/:inviteCode - 通过邀请码查询签名会话

## Service-Party-App 变更
- 新增 account-client.ts HTTP客户端模块
- 集成Account服务API到Electron主进程
- 添加account相关IPC处理器
- 更新preload.ts暴露account API到渲染进程
- Settings页面添加Account服务URL配置

## 文档更新
- 更新 docs/service-party-app.md 反映实际实现
- 添加Account Service HTTP API说明
- 添加签名流程文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-28 22:34:35 -08:00
parent 81c8db9d50
commit de29fa4800
8 changed files with 1250 additions and 125 deletions

View File

@ -3,6 +3,7 @@ package http
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
@ -19,6 +20,7 @@ import (
// This is a completely independent handler that does not affect existing functionality // This is a completely independent handler that does not affect existing functionality
type CoManagedHTTPHandler struct { type CoManagedHTTPHandler struct {
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient sessionCoordinatorClient *grpcclient.SessionCoordinatorClient
db *sql.DB // Database connection for invite_code lookups
} }
// NewCoManagedHTTPHandler creates a new CoManagedHTTPHandler // NewCoManagedHTTPHandler creates a new CoManagedHTTPHandler
@ -27,6 +29,18 @@ func NewCoManagedHTTPHandler(
) *CoManagedHTTPHandler { ) *CoManagedHTTPHandler {
return &CoManagedHTTPHandler{ return &CoManagedHTTPHandler{
sessionCoordinatorClient: sessionCoordinatorClient, sessionCoordinatorClient: sessionCoordinatorClient,
db: nil,
}
}
// NewCoManagedHTTPHandlerWithDB creates a new CoManagedHTTPHandler with database support
func NewCoManagedHTTPHandlerWithDB(
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient,
db *sql.DB,
) *CoManagedHTTPHandler {
return &CoManagedHTTPHandler{
sessionCoordinatorClient: sessionCoordinatorClient,
db: db,
} }
} }
@ -34,9 +48,15 @@ func NewCoManagedHTTPHandler(
func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) { func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) {
coManaged := router.Group("/co-managed") coManaged := router.Group("/co-managed")
{ {
// Keygen session routes
coManaged.POST("/sessions", h.CreateSession) coManaged.POST("/sessions", h.CreateSession)
coManaged.POST("/sessions/:sessionId/join", h.JoinSession) coManaged.POST("/sessions/:sessionId/join", h.JoinSession)
coManaged.GET("/sessions/:sessionId", h.GetSessionStatus) coManaged.GET("/sessions/:sessionId", h.GetSessionStatus)
coManaged.GET("/sessions/by-invite-code/:inviteCode", h.GetSessionByInviteCode)
// Sign session routes (new - does not affect existing functionality)
coManaged.POST("/sign", h.CreateSignSession)
coManaged.GET("/sign/by-invite-code/:inviteCode", h.GetSignSessionByInviteCode)
} }
} }
@ -300,3 +320,323 @@ func (h *CoManagedHTTPHandler) GetSessionStatus(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
// ============================================
// Get Session By Invite Code
// ============================================
// GetSessionByInviteCode handles looking up a session by its invite code
// This allows Service-Party-App to find the session_id from an invite_code
func (h *CoManagedHTTPHandler) GetSessionByInviteCode(c *gin.Context) {
inviteCode := c.Param("inviteCode")
if inviteCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
return
}
// Check if database connection is available
if h.db == nil {
logger.Error("Database connection not available for invite_code lookup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
return
}
// Query database for session by invite_code
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var sessionID string
var walletName string
var thresholdN, thresholdT int
var status string
var expiresAt time.Time
err := h.db.QueryRowContext(ctx, `
SELECT id, COALESCE(wallet_name, ''), threshold_n, threshold_t, status, expires_at
FROM mpc_sessions
WHERE invite_code = $1 AND session_type = 'co_managed_keygen'
`, inviteCode).Scan(&sessionID, &walletName, &thresholdN, &thresholdT, &status, &expiresAt)
if err != nil {
if err == sql.ErrNoRows {
logger.Info("Session not found for invite_code",
zap.String("invite_code", inviteCode))
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
return
}
logger.Error("Failed to query session by invite_code",
zap.String("invite_code", inviteCode),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup session"})
return
}
// Check if session is expired
if time.Now().After(expiresAt) {
logger.Info("Session expired for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID))
c.JSON(http.StatusGone, gin.H{"error": "session has expired"})
return
}
// Get wildcard join token from session coordinator
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
if err != nil {
logger.Error("Failed to get session status from coordinator",
zap.String("session_id", sessionID),
zap.Error(err))
// Return basic info without join token
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": status,
"expires_at": expiresAt.UnixMilli(),
})
return
}
logger.Info("Found session for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID),
zap.String("wallet_name", walletName))
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": statusResp.Status,
"completed_parties": statusResp.CompletedParties,
"total_parties": statusResp.TotalParties,
"expires_at": expiresAt.UnixMilli(),
})
}
// ============================================
// Co-Managed Sign Session (NEW - Independent)
// ============================================
// SignPartyInfo contains party information for signing
type SignPartyInfo struct {
PartyID string `json:"party_id" binding:"required"`
PartyIndex int32 `json:"party_index" binding:"required"`
}
// CreateSignSessionRequest represents the request for creating a co-managed sign session
type CreateSignSessionRequest struct {
KeygenSessionID string `json:"keygen_session_id" binding:"required"` // The keygen session that created the wallet
WalletName string `json:"wallet_name"` // Wallet name (for display)
MessageHash string `json:"message_hash" binding:"required"` // Hex-encoded message hash to sign
Parties []SignPartyInfo `json:"parties" binding:"required,min=1"` // Parties to participate in signing (t+1)
ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold
InitiatorName string `json:"initiator_name"` // Initiator's display name
}
// CreateSignSession handles creating a new co-managed sign session
// This is a completely new endpoint that does not affect existing sign functionality
func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
var req CreateSignSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate keygen_session_id format
if _, err := uuid.Parse(req.KeygenSessionID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen_session_id format"})
return
}
// Validate message_hash (should be hex encoded)
messageHash, err := hex.DecodeString(req.MessageHash)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "message_hash must be hex encoded"})
return
}
// Validate party count >= threshold + 1
if len(req.Parties) < req.ThresholdT+1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("need at least %d parties for threshold %d", req.ThresholdT+1, req.ThresholdT),
})
return
}
// Generate invite code for sign session
inviteCode := generateInviteCode()
// Convert parties to gRPC format
parties := make([]grpcclient.SigningPartyInfo, len(req.Parties))
for i, p := range req.Parties {
parties[i] = grpcclient.SigningPartyInfo{
PartyID: p.PartyID,
PartyIndex: p.PartyIndex,
}
}
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger.Info("Creating co-managed sign session",
zap.String("keygen_session_id", req.KeygenSessionID),
zap.String("wallet_name", req.WalletName),
zap.Int("threshold_t", req.ThresholdT),
zap.Int("num_parties", len(req.Parties)),
zap.String("invite_code", inviteCode))
// Create signing session
// Note: delegateUserShare is nil for co-managed wallets (no delegate party)
resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto(
ctx,
int32(req.ThresholdT),
parties,
messageHash,
86400, // 24 hour expiry
nil, // No delegate share for co-managed wallets
req.KeygenSessionID,
)
if err != nil {
logger.Error("Failed to create co-managed sign session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Store invite_code mapping in database (for lookup)
if h.db != nil {
_, dbErr := h.db.ExecContext(ctx, `
UPDATE mpc_sessions
SET invite_code = $1, wallet_name = $2
WHERE id = $3
`, inviteCode, req.WalletName, resp.SessionID)
if dbErr != nil {
logger.Warn("Failed to store invite_code for sign session",
zap.String("session_id", resp.SessionID),
zap.Error(dbErr))
// Don't fail the request, just log the warning
}
}
// Get wildcard join token for participants
wildcardToken := ""
if token, ok := resp.JoinTokens["*"]; ok {
wildcardToken = token
}
logger.Info("Co-managed sign session created successfully",
zap.String("session_id", resp.SessionID),
zap.String("invite_code", inviteCode),
zap.Int("num_parties", len(resp.SelectedParties)))
c.JSON(http.StatusCreated, gin.H{
"session_id": resp.SessionID,
"keygen_session_id": req.KeygenSessionID,
"wallet_name": req.WalletName,
"invite_code": inviteCode,
"join_token": wildcardToken,
"threshold_t": req.ThresholdT,
"selected_parties": resp.SelectedParties,
"status": "waiting_for_participants",
"current_participants": 0,
"required_participants": len(req.Parties),
"expires_at": resp.ExpiresAt,
})
}
// GetSignSessionByInviteCode handles looking up a sign session by its invite code
// This is a completely new endpoint that does not affect existing functionality
func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
inviteCode := c.Param("inviteCode")
if inviteCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
return
}
// Check if database connection is available
if h.db == nil {
logger.Error("Database connection not available for sign invite_code lookup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
return
}
// Query database for sign session by invite_code
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var sessionID string
var walletName string
var keygenSessionID string
var thresholdN, thresholdT int
var status string
var expiresAt time.Time
err := h.db.QueryRowContext(ctx, `
SELECT id, COALESCE(wallet_name, ''), COALESCE(keygen_session_id::text, ''),
threshold_n, threshold_t, status, expires_at
FROM mpc_sessions
WHERE invite_code = $1 AND session_type = 'sign'
`, inviteCode).Scan(&sessionID, &walletName, &keygenSessionID, &thresholdN, &thresholdT, &status, &expiresAt)
if err != nil {
if err == sql.ErrNoRows {
logger.Info("Sign session not found for invite_code",
zap.String("invite_code", inviteCode))
c.JSON(http.StatusNotFound, gin.H{"error": "sign session not found or expired"})
return
}
logger.Error("Failed to query sign session by invite_code",
zap.String("invite_code", inviteCode),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup sign session"})
return
}
// Check if session is expired
if time.Now().After(expiresAt) {
logger.Info("Sign session expired for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID))
c.JSON(http.StatusGone, gin.H{"error": "sign session has expired"})
return
}
// Get session status from coordinator
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
if err != nil {
logger.Error("Failed to get sign session status from coordinator",
zap.String("session_id", sessionID),
zap.Error(err))
// Return basic info without detailed status
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"keygen_session_id": keygenSessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": status,
"expires_at": expiresAt.UnixMilli(),
})
return
}
logger.Info("Found sign session for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID),
zap.String("wallet_name", walletName))
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"keygen_session_id": keygenSessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": statusResp.Status,
"completed_parties": statusResp.CompletedParties,
"total_parties": statusResp.TotalParties,
"expires_at": expiresAt.UnixMilli(),
})
}

View File

@ -137,6 +137,7 @@ func main() {
getRecoveryStatusUC, getRecoveryStatusUC,
cancelRecoveryUC, cancelRecoveryUC,
sessionCoordinatorClient, sessionCoordinatorClient,
db,
); err != nil { ); err != nil {
errChan <- fmt.Errorf("HTTP server error: %w", err) errChan <- fmt.Errorf("HTTP server error: %w", err)
} }
@ -239,6 +240,7 @@ func startHTTPServer(
getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase, getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase,
cancelRecoveryUC *use_cases.CancelRecoveryUseCase, cancelRecoveryUC *use_cases.CancelRecoveryUseCase,
sessionCoordinatorClient *grpcadapter.SessionCoordinatorClient, sessionCoordinatorClient *grpcadapter.SessionCoordinatorClient,
db *sql.DB,
) error { ) error {
// Set Gin mode // Set Gin mode
if cfg.Server.Environment == "production" { if cfg.Server.Environment == "production" {
@ -301,7 +303,8 @@ func startHTTPServer(
}) })
// Create co-managed wallet handler (independent from existing functionality) // Create co-managed wallet handler (independent from existing functionality)
coManagedHandler := httphandler.NewCoManagedHTTPHandler(sessionCoordinatorClient) // Uses database connection for invite_code lookups
coManagedHandler := httphandler.NewCoManagedHTTPHandlerWithDB(sessionCoordinatorClient, db)
// Configure authentication middleware // Configure authentication middleware
// Skip paths that don't require authentication // Skip paths that don't require authentication

View File

@ -6,6 +6,7 @@ import { GrpcClient } from './modules/grpc-client';
import { DatabaseManager } from './modules/database'; import { DatabaseManager } from './modules/database';
import { addressDerivationService, CHAIN_CONFIGS } from './modules/address-derivation'; import { addressDerivationService, CHAIN_CONFIGS } from './modules/address-derivation';
import { KavaTxService, KAVA_MAINNET_TX_CONFIG } from './modules/kava-tx-service'; import { KavaTxService, KAVA_MAINNET_TX_CONFIG } from './modules/kava-tx-service';
import { AccountClient } from './modules/account-client';
// 内置 HTTP 服务器端口 // 内置 HTTP 服务器端口
const HTTP_PORT = 3456; const HTTP_PORT = 3456;
@ -15,6 +16,7 @@ let grpcClient: GrpcClient | null = null;
let database: DatabaseManager | null = null; let database: DatabaseManager | null = null;
let httpServer: ReturnType<typeof express.application.listen> | null = null; let httpServer: ReturnType<typeof express.application.listen> | null = null;
let kavaTxService: KavaTxService | null = null; let kavaTxService: KavaTxService | null = null;
let accountClient: AccountClient | null = null;
// 创建主窗口 // 创建主窗口
function createWindow() { function createWindow() {
@ -92,6 +94,12 @@ async function initServices() {
// 初始化 Kava 交易服务 // 初始化 Kava 交易服务
kavaTxService = new KavaTxService(KAVA_MAINNET_TX_CONFIG); kavaTxService = new KavaTxService(KAVA_MAINNET_TX_CONFIG);
// 初始化 Account 服务 HTTP 客户端
// 从数据库读取 Account 服务 URL默认使用生产环境地址
const settings = database.getAllSettings();
const accountServiceUrl = settings['account_service_url'] || 'https://api.szaiai.com';
accountClient = new AccountClient(accountServiceUrl);
// 设置 IPC 处理器 // 设置 IPC 处理器
setupIpcHandlers(); setupIpcHandlers();
} }
@ -132,22 +140,70 @@ function setupIpcHandlers() {
} }
}); });
// gRPC - 创建会话 // gRPC - 创建会话 (通过 Account 服务 HTTP API)
ipcMain.handle('grpc:createSession', async (_event, _params) => { ipcMain.handle('grpc:createSession', async (_event, params) => {
// TODO: 实现创建会话逻辑 try {
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' }; const result = await accountClient?.createKeygenSession({
wallet_name: params.walletName,
threshold_t: params.thresholdT,
threshold_n: params.thresholdN,
persistent_count: 0, // 服务端 party 数量,共管钱包模式下为 0
external_count: params.thresholdN, // 所有参与方都是外部 party
expires_in_seconds: 86400, // 24 小时
});
return {
success: true,
sessionId: result?.session_id,
inviteCode: result?.invite_code,
walletName: result?.wallet_name,
expiresAt: result?.expires_at,
joinTokens: result?.join_tokens,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}); });
// gRPC - 验证邀请码 // gRPC - 验证邀请码 (通过 Account 服务 HTTP API)
ipcMain.handle('grpc:validateInviteCode', async (_event, { code: _code }) => { ipcMain.handle('grpc:validateInviteCode', async (_event, { code }) => {
// TODO: 实现验证邀请码逻辑 try {
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' }; const result = await accountClient?.getSessionByInviteCode(code);
return {
success: true,
sessionInfo: {
sessionId: result?.session_id,
walletName: result?.wallet_name,
threshold: {
t: result?.threshold_t,
n: result?.threshold_n,
},
status: result?.status,
currentParticipants: result?.joined_parties || 0,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}); });
// gRPC - 获取会话状态 // gRPC - 获取会话状态 (通过 Account 服务 HTTP API)
ipcMain.handle('grpc:getSessionStatus', async (_event, { sessionId: _sessionId }) => { ipcMain.handle('grpc:getSessionStatus', async (_event, { sessionId }) => {
// TODO: 实现获取会话状态逻辑 try {
return { success: false, error: '功能尚未实现' }; const result = await accountClient?.getSessionStatus(sessionId);
return {
success: true,
session: {
sessionId: result?.session_id,
status: result?.status,
completedParties: result?.completed_parties,
totalParties: result?.total_parties,
sessionType: result?.session_type,
publicKey: result?.public_key,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}); });
// gRPC - 测试连接 // gRPC - 测试连接
@ -161,16 +217,152 @@ function setupIpcHandlers() {
} }
}); });
// gRPC - 验证签名会话 // gRPC - 验证签名会话 (通过 Account 服务 HTTP API)
ipcMain.handle('grpc:validateSigningSession', async (_event, { code: _code }) => { ipcMain.handle('grpc:validateSigningSession', async (_event, { code }) => {
// TODO: 实现验证签名会话逻辑 try {
return { success: false, error: '功能尚未实现 - 需要连接到 Session Coordinator' }; const result = await accountClient?.getSignSessionByInviteCode(code);
return {
success: true,
session: {
sessionId: result?.session_id,
keygenSessionId: result?.keygen_session_id,
walletName: result?.wallet_name,
messageHash: result?.message_hash,
threshold: {
t: result?.threshold_t,
n: result?.parties?.length || 0,
},
currentParticipants: result?.joined_count || 0,
status: result?.status,
parties: result?.parties,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}); });
// gRPC - 加入签名会话 // gRPC - 加入签名会话
ipcMain.handle('grpc:joinSigningSession', async (_event, _params) => { ipcMain.handle('grpc:joinSigningSession', async (_event, params) => {
// TODO: 实现加入签名会话逻辑 try {
return { success: false, error: '功能尚未实现' }; // 从本地 SQLite 获取 share 数据
const share = database?.getShare(params.shareId, params.password);
if (!share) {
return { success: false, error: 'Share not found or incorrect password' };
}
// 加入签名会话 (通过 gRPC)
// TODO: 实际加入会话逻辑需要使用 gRPC client
// 这里先返回成功,表示验证通过
return {
success: true,
partyId: share.party_id,
partyIndex: share.party_index,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===========================================================================
// Account 服务相关 (HTTP API)
// ===========================================================================
// 创建签名会话
ipcMain.handle('account:createSignSession', async (_event, params) => {
try {
// 从本地 SQLite 获取 share 数据
const share = database?.getShare(params.shareId, params.password);
if (!share) {
return { success: false, error: 'Share not found or incorrect password' };
}
// 解析 participants_json 获取参与方列表
const participants = JSON.parse(share.participants_json || '[]');
const parties = participants.map((p: { partyId: string }, index: number) => ({
party_id: p.partyId,
party_index: index,
}));
const result = await accountClient?.createSignSession({
keygen_session_id: share.session_id,
wallet_name: share.wallet_name,
message_hash: params.messageHash,
parties: parties,
threshold_t: share.threshold_t,
initiator_name: params.initiatorName,
});
return {
success: true,
sessionId: result?.session_id,
inviteCode: result?.invite_code,
expiresAt: result?.expires_at,
joinToken: result?.join_token,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 获取签名会话状态 (通过邀请码)
ipcMain.handle('account:getSignSessionByInviteCode', async (_event, { inviteCode }) => {
try {
const result = await accountClient?.getSignSessionByInviteCode(inviteCode);
return {
success: true,
session: {
sessionId: result?.session_id,
keygenSessionId: result?.keygen_session_id,
walletName: result?.wallet_name,
messageHash: result?.message_hash,
thresholdT: result?.threshold_t,
status: result?.status,
inviteCode: result?.invite_code,
expiresAt: result?.expires_at,
parties: result?.parties,
joinedCount: result?.joined_count,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// Account 服务健康检查
ipcMain.handle('account:healthCheck', async () => {
try {
const result = await accountClient?.healthCheck();
return { success: true, data: result };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 测试 Account 服务连接
ipcMain.handle('account:testConnection', async () => {
try {
const connected = await accountClient?.testConnection();
return { success: connected };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 更新 Account 服务 URL
ipcMain.handle('account:updateUrl', async (_event, { url }) => {
try {
accountClient?.setBaseUrl(url);
database?.setSetting('account_service_url', url);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 获取 Account 服务 URL
ipcMain.handle('account:getUrl', async () => {
return accountClient?.getBaseUrl() || 'https://api.szaiai.com';
}); });
// =========================================================================== // ===========================================================================

View File

@ -0,0 +1,333 @@
/**
* Account Service HTTP Client
*
* Service-Party-App Account HTTP API
* / keygen sign
*/
// =============================================================================
// 类型定义
// =============================================================================
// Keygen 会话相关
export interface CreateKeygenSessionRequest {
wallet_name: string;
threshold_t: number;
threshold_n: number;
persistent_count: number;
external_count: number;
expires_in_seconds?: number;
}
export interface CreateKeygenSessionResponse {
session_id: string;
invite_code: string;
wallet_name: string;
threshold_n: number;
threshold_t: number;
selected_server_parties: string[];
join_tokens: Record<string, string>;
expires_at: number;
}
export interface JoinSessionRequest {
party_id: string;
join_token: string;
device_type?: string;
device_id?: string;
}
export interface PartyInfo {
party_id: string;
party_index: number;
}
export interface SessionInfo {
session_id: string;
session_type: string;
threshold_n: number;
threshold_t: number;
status: string;
wallet_name: string;
invite_code: string;
keygen_session_id?: string;
}
export interface JoinSessionResponse {
success: boolean;
party_index: number;
session_info: SessionInfo;
other_parties: PartyInfo[];
}
export interface GetSessionStatusResponse {
session_id: string;
status: string;
completed_parties: number;
total_parties: number;
session_type: string;
public_key?: string;
signature?: string;
has_delegate: boolean;
}
export interface GetSessionByInviteCodeResponse {
session_id: string;
wallet_name: string;
threshold_n: number;
threshold_t: number;
status: string;
invite_code: string;
expires_at: number;
joined_parties: number;
}
// Sign 会话相关
export interface SignPartyInfo {
party_id: string;
party_index: number;
}
export interface CreateSignSessionRequest {
keygen_session_id: string;
wallet_name: string;
message_hash: string;
parties: SignPartyInfo[];
threshold_t: number;
initiator_name?: string;
}
export interface CreateSignSessionResponse {
session_id: string;
invite_code: string;
keygen_session_id: string;
message_hash: string;
threshold_t: number;
parties: SignPartyInfo[];
expires_at: number;
join_token: string;
}
export interface GetSignSessionByInviteCodeResponse {
session_id: string;
keygen_session_id: string;
wallet_name: string;
message_hash: string;
threshold_t: number;
status: string;
invite_code: string;
expires_at: number;
parties: SignPartyInfo[];
joined_count: number;
}
// 错误响应
export interface ErrorResponse {
error: string;
message?: string;
}
// =============================================================================
// HTTP 客户端类
// =============================================================================
export class AccountClient {
private baseUrl: string;
private timeout: number;
/**
*
* @param baseUrl Account URL (例如: https://api.szaiai.com 或 http://localhost:8080)
* @param timeout ()
*/
constructor(baseUrl: string, timeout: number = 30000) {
// 移除末尾的斜杠
this.baseUrl = baseUrl.replace(/\/$/, '');
this.timeout = timeout;
}
/**
* URL
*/
setBaseUrl(baseUrl: string): void {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* HTTP
*/
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
console.log(`[AccountClient] ${method} ${url}`, body ? JSON.stringify(body) : '');
const response = await fetch(url, options);
const text = await response.text();
let data: T | ErrorResponse;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Invalid JSON response: ${text}`);
}
if (!response.ok) {
const errorData = data as ErrorResponse;
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`);
}
console.log(`[AccountClient] Response:`, data);
return data as T;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${this.timeout}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// ===========================================================================
// Keygen 会话 API
// ===========================================================================
/**
* Keygen
*/
async createKeygenSession(
params: CreateKeygenSessionRequest
): Promise<CreateKeygenSessionResponse> {
return this.request<CreateKeygenSessionResponse>(
'POST',
'/api/v1/co-managed/sessions',
params
);
}
/**
*
*/
async joinSession(
sessionId: string,
params: JoinSessionRequest
): Promise<JoinSessionResponse> {
return this.request<JoinSessionResponse>(
'POST',
`/api/v1/co-managed/sessions/${sessionId}/join`,
params
);
}
/**
*
*/
async getSessionStatus(sessionId: string): Promise<GetSessionStatusResponse> {
return this.request<GetSessionStatusResponse>(
'GET',
`/api/v1/co-managed/sessions/${sessionId}`
);
}
/**
* Keygen
*/
async getSessionByInviteCode(inviteCode: string): Promise<GetSessionByInviteCodeResponse> {
return this.request<GetSessionByInviteCodeResponse>(
'GET',
`/api/v1/co-managed/sessions/by-invite-code/${inviteCode}`
);
}
// ===========================================================================
// Sign 会话 API
// ===========================================================================
/**
* Sign
*/
async createSignSession(
params: CreateSignSessionRequest
): Promise<CreateSignSessionResponse> {
return this.request<CreateSignSessionResponse>(
'POST',
'/api/v1/co-managed/sign',
params
);
}
/**
* Sign
*/
async getSignSessionByInviteCode(inviteCode: string): Promise<GetSignSessionByInviteCodeResponse> {
return this.request<GetSignSessionByInviteCodeResponse>(
'GET',
`/api/v1/co-managed/sign/by-invite-code/${inviteCode}`
);
}
// ===========================================================================
// 健康检查
// ===========================================================================
/**
*
*/
async healthCheck(): Promise<{ status: string; service: string }> {
return this.request<{ status: string; service: string }>(
'GET',
'/health'
);
}
/**
*
*/
async testConnection(): Promise<boolean> {
try {
const result = await this.healthCheck();
return result.status === 'healthy';
} catch {
return false;
}
}
}
// =============================================================================
// 默认实例
// =============================================================================
// 默认使用生产环境地址
const DEFAULT_ACCOUNT_SERVICE_URL = 'https://api.szaiai.com';
// 创建默认客户端实例
export const accountClient = new AccountClient(DEFAULT_ACCOUNT_SERVICE_URL);

View File

@ -67,6 +67,36 @@ contextBridge.exposeInMainWorld('electronAPI', {
}, },
}, },
// ===========================================================================
// Account 服务相关 (HTTP API)
// ===========================================================================
account: {
// 创建签名会话
createSignSession: (params: {
shareId: string;
password: string;
messageHash: string;
initiatorName?: string;
}) => ipcRenderer.invoke('account:createSignSession', params),
// 获取签名会话状态 (通过邀请码)
getSignSessionByInviteCode: (inviteCode: string) =>
ipcRenderer.invoke('account:getSignSessionByInviteCode', { inviteCode }),
// 健康检查
healthCheck: () => ipcRenderer.invoke('account:healthCheck'),
// 测试连接
testConnection: () => ipcRenderer.invoke('account:testConnection'),
// 更新服务 URL
updateUrl: (url: string) =>
ipcRenderer.invoke('account:updateUrl', { url }),
// 获取当前服务 URL
getUrl: () => ipcRenderer.invoke('account:getUrl'),
},
// =========================================================================== // ===========================================================================
// 存储相关 (SQLite) // 存储相关 (SQLite)
// =========================================================================== // ===========================================================================

View File

@ -4,6 +4,7 @@ import styles from './Settings.module.css';
interface Settings { interface Settings {
messageRouterUrl: string; messageRouterUrl: string;
accountServiceUrl: string;
autoBackup: boolean; autoBackup: boolean;
backupPath: string; backupPath: string;
} }
@ -13,6 +14,7 @@ export default function Settings() {
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
messageRouterUrl: 'mpc-grpc.szaiai.com:443', // 生产环境默认地址 messageRouterUrl: 'mpc-grpc.szaiai.com:443', // 生产环境默认地址
accountServiceUrl: 'https://api.szaiai.com', // Account 服务默认地址
autoBackup: false, autoBackup: false,
backupPath: '', backupPath: '',
}); });
@ -27,8 +29,12 @@ export default function Settings() {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const result = await window.electronAPI.storage.getSettings(); const result = await window.electronAPI.storage.getSettings();
const accountUrl = await window.electronAPI.account.getUrl();
if (result) { if (result) {
setSettings(result); setSettings({
...result,
accountServiceUrl: accountUrl || 'https://api.szaiai.com',
});
} }
} catch (err) { } catch (err) {
console.error('Failed to load settings:', err); console.error('Failed to load settings:', err);
@ -43,6 +49,7 @@ export default function Settings() {
try { try {
await window.electronAPI.storage.saveSettings(settings); await window.electronAPI.storage.saveSettings(settings);
await window.electronAPI.account.updateUrl(settings.accountServiceUrl);
setMessage({ type: 'success', text: '设置已保存' }); setMessage({ type: 'success', text: '设置已保存' });
} catch (err) { } catch (err) {
setMessage({ type: 'error', text: '保存设置失败' }); setMessage({ type: 'error', text: '保存设置失败' });
@ -57,12 +64,29 @@ export default function Settings() {
try { try {
const result = await window.electronAPI.grpc.testConnection(settings.messageRouterUrl); const result = await window.electronAPI.grpc.testConnection(settings.messageRouterUrl);
if (result.success) { if (result.success) {
setMessage({ type: 'success', text: '连接成功' }); setMessage({ type: 'success', text: 'Message Router 连接成功' });
} else { } else {
setMessage({ type: 'error', text: result.error || '连接失败' }); setMessage({ type: 'error', text: result.error || 'Message Router 连接失败' });
} }
} catch (err) { } catch (err) {
setMessage({ type: 'error', text: '连接测试失败' }); setMessage({ type: 'error', text: 'Message Router 连接测试失败' });
}
};
const handleTestAccountConnection = async () => {
setMessage(null);
try {
// 先更新 URL
await window.electronAPI.account.updateUrl(settings.accountServiceUrl);
const result = await window.electronAPI.account.testConnection();
if (result.success) {
setMessage({ type: 'success', text: 'Account 服务连接成功' });
} else {
setMessage({ type: 'error', text: result.error || 'Account 服务连接失败' });
}
} catch (err) {
setMessage({ type: 'error', text: 'Account 服务连接测试失败' });
} }
}; };
@ -120,6 +144,28 @@ export default function Settings() {
Message Router gRPC (生产环境: mpc-grpc.szaiai.com:443) Message Router gRPC (生产环境: mpc-grpc.szaiai.com:443)
</p> </p>
</div> </div>
<div className={styles.field}>
<label className={styles.label}>Account </label>
<div className={styles.inputWithButton}>
<input
type="text"
value={settings.accountServiceUrl}
onChange={(e) => setSettings(prev => ({ ...prev, accountServiceUrl: e.target.value }))}
placeholder="https://api.szaiai.com"
className={styles.input}
/>
<button
className={styles.testButton}
onClick={handleTestAccountConnection}
>
</button>
</div>
<p className={styles.hint}>
Account HTTP (生产环境: https://api.szaiai.com)
</p>
</div>
</div> </div>
</section> </section>

View File

@ -178,6 +178,55 @@ interface JoinSigningSessionParams {
} }
interface JoinSigningSessionResult { interface JoinSigningSessionResult {
success: boolean;
partyId?: string;
partyIndex?: number;
error?: string;
}
// Account 服务相关类型
interface CreateSignSessionParams {
shareId: string;
password: string;
messageHash: string;
initiatorName?: string;
}
interface CreateSignSessionResult {
success: boolean;
sessionId?: string;
inviteCode?: string;
expiresAt?: number;
joinToken?: string;
error?: string;
}
interface SignSessionInfo {
sessionId: string;
keygenSessionId: string;
walletName: string;
messageHash: string;
thresholdT: number;
status: string;
inviteCode: string;
expiresAt: number;
parties: Array<{ party_id: string; party_index: number }>;
joinedCount: number;
}
interface GetSignSessionByInviteCodeResult {
success: boolean;
session?: SignSessionInfo;
error?: string;
}
interface AccountHealthCheckResult {
success: boolean;
data?: { status: string; service: string };
error?: string;
}
interface AccountTestConnectionResult {
success: boolean; success: boolean;
error?: string; error?: string;
} }
@ -346,6 +395,16 @@ interface KavaHealthCheckResult {
// =========================================================================== // ===========================================================================
interface ElectronAPI { interface ElectronAPI {
// Account 服务相关 (HTTP API)
account: {
createSignSession: (params: CreateSignSessionParams) => Promise<CreateSignSessionResult>;
getSignSessionByInviteCode: (inviteCode: string) => Promise<GetSignSessionByInviteCodeResult>;
healthCheck: () => Promise<AccountHealthCheckResult>;
testConnection: () => Promise<AccountTestConnectionResult>;
updateUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
getUrl: () => Promise<string>;
};
// gRPC 相关 // gRPC 相关
grpc: { grpc: {
createSession: (params: CreateSessionParams) => Promise<CreateSessionResult>; createSession: (params: CreateSessionParams) => Promise<CreateSessionResult>;

View File

@ -2,38 +2,56 @@
## 概述 ## 概述
Service Party App 是一个**跨平台 Electron 桌面应用**,用于参与分布式多方共管钱包的创建和签名过程。用户可以在各自的电脑上运行此应用,通过扫描邀请二维码加入共管钱包创建会话,参与 TSS (Threshold Signature Scheme) 密钥生成协议。 Service Party App 是一个**跨平台 Electron 桌面应用**,用于参与分布式多方共管钱包的创建和签名过程。用户可以在各自的电脑上运行此应用,通过扫描邀请二维码或输入邀请码加入共管钱包创建会话,参与 TSS (Threshold Signature Scheme) 密钥生成协议。
## 应用场景 ## 应用场景
``` ```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
Admin Web │ │ Service Party │ │ Service Party │ Service Party │ │ Service Party │ │ Service Party │
(管理员电脑) │ │ App (用户A电脑) │ │ App (用户B电脑) App (用户A) │ │ App (用户B) │ │ App (用户C)
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │ │ │
│ 创建会话/生成邀请码 │ 扫码加入 │ 扫码加入 │ 创建会话/生成邀请码 │ 输入邀请码加入 │ 输入邀请码加入
│ │ │ │ │ │
└───────────────────────┼───────────────────────┘ └───────────────────────┼───────────────────────┘
┌────────────┴────────────┐ ┌────────────┴────────────┐
│ Account Service │
│ (HTTP API - 会话管理) │
└────────────┬────────────┘
┌────────────┴────────────┐
│ Message Router │ │ Message Router │
│ (gRPC 消息路由) │ │ (gRPC 消息路由) │
└────────────┬────────────┘ └────────────┬────────────┘
┌────────────┴────────────┐ ┌────────────┴────────────┐
│ TSS Keygen Protocol │ TSS Keygen/Sign
│ (分布式密钥生成) │ (分布式密钥生成/签名) │
└─────────────────────────┘ └─────────────────────────┘
``` ```
### 典型使用流程 ### 典型使用流程
1. **管理员** 在 Admin Web 创建共管钱包会话,配置阈值 (如 2-of-3) #### Keygen 流程 (密钥生成)
2. **管理员** 生成邀请二维码,分享给参与方
3. **参与方** 在各自电脑上打开 Service Party App扫描二维码加入 1. **发起方** 在 Service Party App 输入钱包名称和用户名称,创建共管钱包会话
4. **所有参与方就绪后**,自动开始 TSS 密钥生成协议 2. **发起方** 获得邀请码 (格式: XXXX-XXXX-XXXX),分享给其他参与方
5. **完成后**,每个参与方本地保存自己的加密 share管理员获得钱包公钥 3. **参与方** 在各自电脑上打开 Service Party App输入邀请码加入
4. **等待所有参与方就绪** (24小时超时)
5. **所有参与方到齐后**,自动开始 TSS 密钥生成协议
6. **完成后**,每个参与方在本地 SQLite 保存自己的加密 share
#### Sign 流程 (签名)
1. **发起方** 从本地 SQLite 选择要签名的钱包
2. **发起方** 输入待签名的消息哈希,创建签名会话
3. **发起方** 获得签名邀请码,分享给其他参与方 (必须是 keygen 时的参与方)
4. **参与方** 输入邀请码加入签名会话
5. **等待足够数量的参与方** (threshold_t + 1 人24小时超时)
6. **参与方到齐后**,自动开始 TSS 签名协议
7. **完成后**,返回签名结果,可用于广播交易
## 目录结构 ## 目录结构
@ -44,45 +62,44 @@ backend/mpc-system/services/service-party-app/
│ ├── preload.ts # 预加载脚本 (安全 IPC) │ ├── preload.ts # 预加载脚本 (安全 IPC)
│ └── modules/ │ └── modules/
│ ├── grpc-client.ts # gRPC 客户端 (连接 Message Router) │ ├── grpc-client.ts # gRPC 客户端 (连接 Message Router)
│ ├── account-client.ts # HTTP 客户端 (连接 Account Service)
│ ├── tss-handler.ts # TSS 协议处理器 │ ├── tss-handler.ts # TSS 协议处理器
│ └── storage.ts # 本地加密存储 (AES-256-GCM) │ ├── database.ts # 本地 SQLite 存储 (AES-256-GCM 加密)
│ ├── address-derivation.ts # 地址派生 (多链支持)
│ ├── kava-client.ts # Kava 区块链客户端
│ └── kava-tx-service.ts # Kava 交易构建服务
├── src/ # React 前端 (渲染进程) ├── src/ # React 前端 (渲染进程)
│ ├── App.tsx # 应用入口 │ ├── App.tsx # 应用入口
│ ├── pages/ │ ├── pages/
│ │ ├── Home.tsx # 主页 - Share 列表 │ │ ├── Home.tsx # 主页 - Share 列表
│ │ ├── Join.tsx # 加入会话页面 │ │ ├── Join.tsx # 加入会话页面
│ │ ├── Create.tsx # 创建会话页面 (可选) │ │ ├── Create.tsx # 创建会话页面
│ │ ├── Session.tsx # 会话进度页面 │ │ ├── Session.tsx # 会话进度页面
│ │ ├── Sign.tsx # 签名页面
│ │ └── Settings.tsx # 设置页面 │ │ └── Settings.tsx # 设置页面
│ ├── components/ │ ├── components/ # UI 组件
│ │ ├── ShareCard.tsx # Share 卡片组件 │ ├── stores/
│ │ ├── JoinForm.tsx # 加入表单 │ │ └── appStore.ts # Zustand 状态管理
│ │ ├── ProgressSteps.tsx # 进度步骤 │ ├── types/
│ │ ├── ExportDialog.tsx # 导出对话框 │ │ └── electron.d.ts # TypeScript 类型定义
│ │ └── ImportDialog.tsx # 导入对话框 │ └── utils/
│ └── hooks/ │ └── address.ts # 地址工具函数
│ ├── useGrpc.ts # gRPC 连接 Hook
│ ├── useSession.ts # 会话状态 Hook
│ └── useStorage.ts # 本地存储 Hook
├── tss-party/ # Go TSS 子进程 ├── tss-party/ # Go TSS 子进程
│ ├── main.go # TSS 协议执行程序 │ ├── main.go # TSS 协议执行程序
│ ├── go.mod # Go 模块定义 │ ├── go.mod # Go 模块定义
│ └── go.sum # 依赖锁定 │ └── go.sum # 依赖锁定
├── bin/ # 编译后的二进制文件 ├── proto/ # gRPC Proto 文件
│ ├── win32-x64/ │ └── message_router.proto # Message Router 接口定义
│ │ └── tss-party.exe
│ ├── darwin-x64/
│ │ └── tss-party
│ └── linux-x64/
│ └── tss-party
├── package.json # Node.js 依赖 ├── package.json # Node.js 依赖
├── electron-builder.json # Electron 打包配置 ├── electron-builder.json # Electron 打包配置
├── tsconfig.json # TypeScript 配置 ├── tsconfig.json # TypeScript 配置
└── vite.config.ts # Vite 构建配置 ├── tsconfig.electron.json # Electron TypeScript 配置
├── vite.config.ts # Vite 构建配置
└── build-windows.bat # Windows 一键编译脚本
``` ```
## 技术架构 ## 技术架构
@ -95,12 +112,50 @@ backend/mpc-system/services/service-party-app/
| 前端框架 | React 18 + TypeScript | 与 admin-web 保持一致 | | 前端框架 | React 18 + TypeScript | 与 admin-web 保持一致 |
| 构建工具 | Vite | 快速开发和构建 | | 构建工具 | Vite | 快速开发和构建 |
| 状态管理 | Zustand | 轻量级状态管理 | | 状态管理 | Zustand | 轻量级状态管理 |
| gRPC 客户端 | @grpc/grpc-js | Node.js gRPC 实现 | | gRPC 客户端 | @grpc/grpc-js | Node.js gRPC 实现 (连接 Message Router) |
| HTTP 客户端 | Fetch API | 连接 Account Service |
| TSS 协议 | Go 子进程 | 使用 bnb-chain/tss-lib | | TSS 协议 | Go 子进程 | 使用 bnb-chain/tss-lib |
| 本地存储 | electron-store | 加密本地存储 | | 本地存储 | sql.js (SQLite) | 纯 JavaScript SQLite 实现 |
| 加密算法 | AES-256-GCM | Share 加密存储 | | 加密算法 | AES-256-GCM | Share 加密存储PBKDF2 密钥派生 |
| 打包工具 | electron-builder | 多平台打包 | | 打包工具 | electron-builder | 多平台打包 |
### 服务连接
```
┌─────────────────────────────────────────────────────────────┐
│ Service Party App │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ main.ts ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ grpcClient │ │accountClient │ │ database │ ││
│ │ │ (gRPC) │ │ (HTTP) │ │ (SQLite) │ ││
│ │ └──────┬───────┘ └──────┬───────┘ └──────────────┘ ││
│ │ │ │ ││
│ └─────────┼─────────────────┼──────────────────────────────┘│
└────────────┼─────────────────┼───────────────────────────────┘
│ │
│ gRPC (TLS) │ HTTPS
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Message Router │ │ Account Service │
│ (消息路由) │ │ (会话管理) │
└─────────────────┘ └─────────────────┘
```
### Account Service HTTP API
Service Party App 通过 HTTP 调用 Account Service 管理会话:
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/v1/co-managed/sessions` | POST | 创建 Keygen 会话 |
| `/api/v1/co-managed/sessions/:sessionId/join` | POST | 加入会话 |
| `/api/v1/co-managed/sessions/:sessionId` | GET | 获取会话状态 |
| `/api/v1/co-managed/sessions/by-invite-code/:code` | GET | 通过邀请码查询会话 |
| `/api/v1/co-managed/sign` | POST | 创建 Sign 会话 |
| `/api/v1/co-managed/sign/by-invite-code/:code` | GET | 通过邀请码查询签名会话 |
| `/health` | GET | 健康检查 |
### TSS 子进程架构 ### TSS 子进程架构
为什么使用 Go 子进程而不是 WASM 为什么使用 Go 子进程而不是 WASM
@ -128,9 +183,10 @@ backend/mpc-system/services/service-party-app/
│ ┌────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────┐ │
│ │ - 使用 bnb-chain/tss-lib v2 │ │ │ │ - 使用 bnb-chain/tss-lib v2 │ │
│ │ - 执行 GG20 Keygen 协议 (4 轮) │ │ │ │ - 执行 GG20 Keygen 协议 (4 轮) │ │
│ │ - 执行 GG20 Signing 协议 │ │
│ │ - 通过 stdin 接收 MPC 消息 │ │ │ │ - 通过 stdin 接收 MPC 消息 │ │
│ │ - 通过 stdout 发送 MPC 消息 │ │ │ │ - 通过 stdout 发送 MPC 消息 │ │
│ │ - 完成后返回公钥 + 加密 share │ │ │ │ - 完成后返回公钥 + 加密 share / 签名结果 │ │
│ └────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────┘
``` ```
@ -154,8 +210,9 @@ interface OutgoingMessage {
isBroadcast?: boolean; isBroadcast?: boolean;
toParties?: string[]; toParties?: string[];
payload?: string; // base64 encoded payload?: string; // base64 encoded
publicKey?: string; // base64 encoded (result) publicKey?: string; // base64 encoded (keygen result)
encryptedShare?: string; // base64 encoded (result) encryptedShare?: string; // base64 encoded (keygen result)
signature?: string; // base64 encoded (sign result)
partyIndex?: number; partyIndex?: number;
round?: number; round?: number;
totalRounds?: number; totalRounds?: number;
@ -167,13 +224,13 @@ interface OutgoingMessage {
### 1. 加入会话 ### 1. 加入会话
用户通过以下方式加入共管钱包创建会话: 用户通过以下方式加入共管钱包创建/签名会话:
- **扫描二维码**: 使用摄像头扫描 Admin Web 生成的邀请二维码 - **输入邀请码**: 手动输入 12 位邀请码 (格式: XXXX-XXXX-XXXX)
- **粘贴邀请链接**: 手动粘贴邀请链接 - **扫描二维码**: 使用摄像头扫描邀请二维码 (可选)
- **输入邀请码**: 手动输入 6 位邀请码 - **粘贴邀请链接**: 手动粘贴邀请链接 (可选)
### 2. TSS 密钥生成 ### 2. TSS 密钥生成 (Keygen)
参与 GG20 (Gennaro-Goldfeder 2020) 门限签名密钥生成协议: 参与 GG20 (Gennaro-Goldfeder 2020) 门限签名密钥生成协议:
@ -181,35 +238,58 @@ interface OutgoingMessage {
- 4 轮消息交换 - 4 轮消息交换
- 零知识证明保证安全性 - 零知识证明保证安全性
- 每个参与方获得自己的 share无需信任其他方 - 每个参与方获得自己的 share无需信任其他方
- 24 小时超时等待所有参与方
### 3. 本地加密存储 ### 3. TSS 签名 (Sign)
Share 使用 AES-256-GCM 加密后存储在本地: 参与 GG20 门限签名协议:
- 使用 Keygen 时保存的 share
- 仅需 threshold_t + 1 个参与方即可签名
- 24 小时超时等待足够参与方
- 返回可用于广播的签名结果
### 4. 本地 SQLite 存储
Share 使用 AES-256-GCM 加密后存储在本地 SQLite 数据库:
```typescript ```typescript
interface ShareEntry { interface ShareRecord {
id: string; // Share 唯一标识 id: string; // Share 唯一标识 (UUID)
sessionId: string; // 会话 ID session_id: string; // Keygen 会话 ID
walletName: string; // 钱包名称 wallet_name: string; // 钱包名称
partyId: string; // 参与方 ID party_id: string; // 参与方 ID
partyIndex: number; // 参与方索引 party_index: number; // 参与方索引
threshold: { threshold_t: number; // 签名阈值
t: number; // 签名阈值 threshold_n: number; // 总参与方数
n: number; // 总参与方数 public_key_hex: string; // 钱包公钥 (hex)
}; encrypted_share: string; // AES-256-GCM 加密的 share
publicKey: string; // 钱包公钥 (hex) created_at: string; // 创建时间
encryptedShare: string; // AES-256-GCM 加密的 share last_used_at: string | null; // 最后使用时间
createdAt: string; // 创建时间 participants_json: string; // JSON: 参与方列表 [{partyId, name}]
metadata: {
participants: Array<{
partyId: string;
name: string;
}>;
};
} }
``` ```
### 4. 备份与恢复 **重要**: `participants_json` 字段存储了 Keygen 时所有参与方的信息Sign 时必须从这里选择参与方。
### 5. 地址派生
支持从公钥派生多链地址:
- **Kava** (主链)
- **Cosmos** 系列链
- **Ethereum** 兼容链
### 6. Kava 交易
支持构建和广播 Kava 交易:
- 查询余额
- 构建转账交易 (待 TSS 签名)
- 广播已签名交易
- 查询交易状态
### 7. 备份与恢复
- **导出备份**: 将加密的 share 导出为文件 - **导出备份**: 将加密的 share 导出为文件
- **导入恢复**: 从备份文件恢复 share - **导入恢复**: 从备份文件恢复 share
@ -221,7 +301,14 @@ interface ShareEntry {
- Node.js 18+ - Node.js 18+
- Go 1.21+ - Go 1.21+
- pnpm 或 npm - npm 或 pnpm
### Windows 一键编译
```bash
cd backend/mpc-system/services/service-party-app
build-windows.bat
```
### 编译 TSS 子进程 ### 编译 TSS 子进程
@ -230,9 +317,12 @@ interface ShareEntry {
cd backend/mpc-system/services/service-party-app/tss-party cd backend/mpc-system/services/service-party-app/tss-party
go build -o ../bin/win32-x64/tss-party.exe . go build -o ../bin/win32-x64/tss-party.exe .
# macOS # macOS (Intel)
GOOS=darwin GOARCH=amd64 go build -o ../bin/darwin-x64/tss-party . GOOS=darwin GOARCH=amd64 go build -o ../bin/darwin-x64/tss-party .
# macOS (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o ../bin/darwin-arm64/tss-party .
# Linux # Linux
GOOS=linux GOARCH=amd64 go build -o ../bin/linux-x64/tss-party . GOOS=linux GOARCH=amd64 go build -o ../bin/linux-x64/tss-party .
``` ```
@ -252,39 +342,51 @@ npm run dev
### 生产构建 ### 生产构建
```bash ```bash
# 构建前端 # 构建前端 + Electron
npm run build npm run build
# 打包 Electron 应用 # 打包 Windows 安装包
npm run package npm run build:win
# 输出目录: dist/ # 输出目录: release/
``` ```
## 配置
### 设置页面配置项
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| Message Router 地址 | gRPC 服务地址 | mpc-grpc.szaiai.com:443 |
| Account 服务地址 | HTTP API 地址 | https://api.szaiai.com |
| 自动备份 | 创建 share 后自动备份 | false |
| 备份目录 | 自动备份文件保存位置 | (空) |
## 安全考虑 ## 安全考虑
### Share 安全 ### Share 安全
1. **本地加密**: Share 使用 AES-256-GCM 加密存储 1. **本地加密**: Share 使用 AES-256-GCM 加密存储
2. **密钥派生**: 加密密钥由用户密码通过 PBKDF2 派生 2. **密钥派生**: 加密密钥由用户密码通过 PBKDF2 派生 (100,000 次迭代)
3. **内存保护**: Share 在内存中的时间尽量短 3. **内存保护**: Share 在内存中的时间尽量短
4. **安全删除**: 删除 share 时安全擦除 4. **安全删除**: 删除 share 时同时删除派生地址和签名历史
### 网络安全 ### 网络安全
1. **TLS 加密**: 与 Message Router 的 gRPC 连接使用 TLS 1. **TLS 加密**: 与 Message Router 的 gRPC 连接使用 TLS (端口 443)
2. **消息签名**: MPC 消息包含签名验证 2. **HTTPS**: 与 Account Service 的 HTTP 连接使用 HTTPS
3. **会话隔离**: 每个会话使用独立的密钥对 3. **消息签名**: MPC 消息包含签名验证
4. **会话隔离**: 每个会话使用独立的密钥对
### 应用安全 ### 应用安全
1. **代码签名**: 发布的应用经过代码签名 1. **代码签名**: 发布的应用经过代码签名
2. **自动更新**: 支持安全的自动更新机制 2. **沙箱隔离**: Electron 渲染进程在沙箱中运行
3. **沙箱隔离**: Electron 渲染进程在沙箱中运行 3. **Context Isolation**: preload 脚本使用 contextBridge 安全暴露 API
## 与现有系统的集成 ## 与现有系统的集成
### 与 Message Router 的通信 ### 与 Message Router 的通信 (gRPC)
``` ```
Service Party App Service Party App
@ -295,7 +397,7 @@ Service Party App
│ Message Router │ │ Message Router │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ RegisterParty │ │ ← 注册为参与方 │ │ RegisterParty │ │ ← 注册为参与方
│ │ Heartbeat │ │ ← 心跳保活 │ │ Heartbeat │ │ ← 心跳保活 (30秒)
│ │ JoinSession │ │ ← 加入会话 │ │ JoinSession │ │ ← 加入会话
│ │ Subscribe │ │ ← 订阅消息 │ │ Subscribe │ │ ← 订阅消息
│ │ RouteMessage │ │ ← 发送消息 │ │ RouteMessage │ │ ← 发送消息
@ -304,36 +406,56 @@ Service Party App
└─────────────────────┘ └─────────────────────┘
``` ```
### 与 Admin Web 的协作 ### 与 Account Service 的通信 (HTTP)
1. Admin Web 创建 `co_managed_keygen` 类型的会话 ```
2. Session Coordinator 生成邀请码 Service Party App
3. Admin Web 显示邀请二维码
4. Service Party App 扫码获取会话信息 │ HTTPS
5. 双方通过 Message Router 交换 MPC 消息
6. 完成后各自获得结果 ┌─────────────────────┐
│ Account Service │
│ ┌───────────────┐ │
│ │ CreateSession │ │ ← 创建 Keygen/Sign 会话
│ │ JoinSession │ │ ← 加入会话
│ │ GetSession │ │ ← 查询会话状态
│ │ ByInviteCode │ │ ← 通过邀请码查询
│ └───────────────┘ │
└─────────────────────┘
```
## 未来扩展 ### 数据流
### 签名功能 1. **Keygen 创建**:
- App → Account Service: 创建会话,获取 session_id + invite_code
- App → Message Router: RegisterParty
- App → Message Router: SubscribeSessionEvents
- 等待其他参与方加入...
当前版本仅支持密钥生成,未来将支持: 2. **Keygen 加入**:
- App → Account Service: 通过 invite_code 查询 session_id
- App → Message Router: RegisterParty
- App → Account Service: JoinSession
- 等待所有参与方就绪...
- 参与门限签名 3. **Keygen 执行**:
- 支持多种签名算法 (ECDSA, EdDSA) - Message Router 通知所有参与方开始
- 批量签名 - 各 App 启动 TSS 子进程
- 通过 Message Router 交换 MPC 消息
- 完成后各自保存 share 到本地 SQLite
### 密钥刷新 4. **Sign 创建**:
- App → SQLite: 读取 share 和 participants_json
- App → Account Service: 创建签名会话,指定参与方列表
- 分享 invite_code 给其他参与方
- 支持 share 刷新而不改变公钥 5. **Sign 执行**:
- 支持增加/减少参与方 - 足够参与方加入后自动开始
- 通过 Message Router 交换 MPC 消息
### 硬件钱包集成 - 完成后返回签名结果
- 支持将 share 存储在硬件安全模块
- 支持 Ledger/Trezor 等硬件钱包
## 相关文档 ## 相关文档
- [共管钱包实现计划](./co-managed-wallet-implementation-plan.md) - [共管钱包实现计划](./co-managed-wallet-implementation-plan.md)
- [MPC 系统架构](./architecture/) - [MPC 系统架构](../backend/mpc-system/docs/01-architecture.md)
- [API 参考](../backend/mpc-system/docs/02-api-reference.md)