feat(service-party-app): implement co-sign multi-party signing
Add complete co-sign functionality for multi-party transaction signing: Frontend (React): - CoSignCreate.tsx: Create signing session with share selection - CoSignJoin.tsx: Join signing session via invite code - CoSignSession.tsx: Monitor signing progress and results - Add routes in App.tsx for new pages Backend (Electron): - main.ts: Add IPC handlers for co-sign operations - tss-handler.ts: Add participateSign() for TSS signing - preload.ts: Expose cosign API to renderer - account-client.ts: Add sign session API types TSS Party (Go): - main.go: Implement 'sign' command for GG20 signing protocol - integration_test.go: Add comprehensive tests for signing flow Infrastructure: - docker-compose.windows.yml: Expose gRPC port 50051 This is a pure additive change that does not affect existing persistent role keygen/sign functionality. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7696f663a5
commit
ebea74e57b
|
|
@ -159,6 +159,7 @@ services:
|
||||||
dockerfile: services/message-router/Dockerfile
|
dockerfile: services/message-router/Dockerfile
|
||||||
container_name: mpc-message-router
|
container_name: mpc-message-router
|
||||||
ports:
|
ports:
|
||||||
|
- "50051:50051" # gRPC for party connections
|
||||||
- "8082:8080"
|
- "8082:8080"
|
||||||
environment:
|
environment:
|
||||||
MPC_SERVER_GRPC_PORT: 50051
|
MPC_SERVER_GRPC_PORT: 50051
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,26 @@ let activeKeygenSession: ActiveKeygenSession | null = null;
|
||||||
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
|
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
|
||||||
let keygenInProgressSessionId: string | null = null;
|
let keygenInProgressSessionId: string | null = null;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关状态
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 当前正在进行的 Co-Sign 会话信息
|
||||||
|
interface ActiveCoSignSession {
|
||||||
|
sessionId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
participants: Array<{ partyId: string; partyIndex: number; name: string }>;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
walletName: string;
|
||||||
|
messageHash: string;
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
}
|
||||||
|
let activeCoSignSession: ActiveCoSignSession | null = null;
|
||||||
|
|
||||||
|
// Co-Sign 幂等性保护:追踪正在进行的签名会话 ID
|
||||||
|
let signInProgressSessionId: string | null = null;
|
||||||
|
|
||||||
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
|
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
|
||||||
// 当事件到达时,前端可能还在页面导航中,尚未订阅
|
// 当事件到达时,前端可能还在页面导航中,尚未订阅
|
||||||
interface SessionEventData {
|
interface SessionEventData {
|
||||||
|
|
@ -619,6 +639,295 @@ async function handleKeygenComplete(result: KeygenResult) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关处理函数
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 检查并触发 Co-Sign(在收到 all_joined 事件后调用)
|
||||||
|
async function checkAndTriggerCoSign(sessionId: string) {
|
||||||
|
console.log('[CO-SIGN] checkAndTriggerCoSign called with sessionId:', sessionId);
|
||||||
|
|
||||||
|
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
|
||||||
|
console.log('[CO-SIGN] No matching active co-sign session for', sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Active session found:', {
|
||||||
|
sessionId: activeCoSignSession.sessionId,
|
||||||
|
partyIndex: activeCoSignSession.partyIndex,
|
||||||
|
threshold: activeCoSignSession.threshold,
|
||||||
|
participantCount: activeCoSignSession.participants.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果 TSS 已经在运行,不重复触发
|
||||||
|
if (tssHandler?.getIsRunning()) {
|
||||||
|
console.log('[CO-SIGN] TSS already running, skip check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollIntervalMs = 2000; // 2秒轮询间隔
|
||||||
|
const maxWaitMs = 5 * 60 * 1000; // 5分钟超时
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Starting to poll session status...');
|
||||||
|
debugLog.info('main', `Starting to poll co-sign session status for ${sessionId}`);
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
// 检查会话是否仍然有效
|
||||||
|
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
|
||||||
|
debugLog.warn('main', 'Active co-sign session changed, stopping poll');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 TSS 已经在运行,停止轮询
|
||||||
|
if (tssHandler?.getIsRunning()) {
|
||||||
|
debugLog.info('main', 'TSS started running, stopping poll');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取签名会话状态
|
||||||
|
const status = await accountClient?.getSignSessionStatus(sessionId);
|
||||||
|
if (!status) {
|
||||||
|
debugLog.warn('main', 'Failed to get sign session status, will retry');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedT = activeCoSignSession.threshold.t;
|
||||||
|
const currentParticipants = status.joined_count || 0;
|
||||||
|
|
||||||
|
debugLog.debug('main', `Sign session ${sessionId} status: ${status.status}, participants: ${currentParticipants}/${expectedT}`);
|
||||||
|
|
||||||
|
// 检查是否满足启动条件
|
||||||
|
const hasAllParticipants = currentParticipants >= expectedT;
|
||||||
|
const statusReady = status.status === 'in_progress' ||
|
||||||
|
status.status === 'all_joined' ||
|
||||||
|
status.status === 'waiting_for_sign';
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Check conditions:', {
|
||||||
|
hasAllParticipants,
|
||||||
|
statusReady,
|
||||||
|
currentParticipants,
|
||||||
|
expectedT,
|
||||||
|
status: status.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasAllParticipants && statusReady) {
|
||||||
|
console.log('[CO-SIGN] Conditions met! Triggering sign...');
|
||||||
|
debugLog.info('main', `All ${expectedT} participants joined (status: ${status.status}), triggering sign...`);
|
||||||
|
|
||||||
|
// 更新参与者列表
|
||||||
|
if (status.parties && status.parties.length > 0) {
|
||||||
|
const myPartyId = grpcClient?.getPartyId();
|
||||||
|
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
|
||||||
|
|
||||||
|
for (const p of status.parties) {
|
||||||
|
const existing = activeCoSignSession.participants.find(ep => ep.partyId === p.party_id);
|
||||||
|
updatedParticipants.push({
|
||||||
|
partyId: p.party_id,
|
||||||
|
partyIndex: p.party_index,
|
||||||
|
name: existing?.name || (p.party_id === myPartyId ? '我' : `参与方 ${p.party_index + 1}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
activeCoSignSession.participants = updatedParticipants;
|
||||||
|
|
||||||
|
const myInfo = updatedParticipants.find(p => p.partyId === myPartyId);
|
||||||
|
if (myInfo) {
|
||||||
|
activeCoSignSession.partyIndex = myInfo.partyIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedParties = activeCoSignSession.participants.map(p => p.partyId);
|
||||||
|
|
||||||
|
await handleCoSignStart({
|
||||||
|
eventType: 'session_started',
|
||||||
|
sessionId: sessionId,
|
||||||
|
thresholdT: activeCoSignSession.threshold.t,
|
||||||
|
thresholdN: activeCoSignSession.threshold.n,
|
||||||
|
selectedParties: selectedParties,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
debugLog.error('main', `Failed to check sign session status: ${error}, will retry`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
debugLog.error('main', `Timeout: failed to start sign within 5 minutes for session ${sessionId}`);
|
||||||
|
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send(`cosign:events:${sessionId}`, {
|
||||||
|
type: 'sign_start_timeout',
|
||||||
|
error: '启动签名超时,请重试',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Co-Sign 会话开始事件 - 触发签名
|
||||||
|
async function handleCoSignStart(event: {
|
||||||
|
eventType: string;
|
||||||
|
sessionId: string;
|
||||||
|
thresholdT: number;
|
||||||
|
thresholdN: number;
|
||||||
|
selectedParties: string[];
|
||||||
|
}) {
|
||||||
|
console.log('[CO-SIGN] handleCoSignStart called:', {
|
||||||
|
eventType: event.eventType,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
thresholdT: event.thresholdT,
|
||||||
|
thresholdN: event.thresholdN,
|
||||||
|
selectedParties: event.selectedParties?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeCoSignSession) {
|
||||||
|
console.log('[CO-SIGN] No active co-sign session, ignoring');
|
||||||
|
debugLog.debug('main', 'No active co-sign session, ignoring sign start event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeCoSignSession.sessionId !== event.sessionId) {
|
||||||
|
debugLog.debug('main', `Session ID mismatch: expected ${activeCoSignSession.sessionId}, got ${event.sessionId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 幂等性保护
|
||||||
|
if (signInProgressSessionId === event.sessionId) {
|
||||||
|
debugLog.debug('main', `Sign already in progress for session ${event.sessionId}, skipping duplicate trigger`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tssHandler?.getIsRunning()) {
|
||||||
|
debugLog.debug('main', 'TSS already running, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tssHandler || !('participateSign' in tssHandler)) {
|
||||||
|
debugLog.error('tss', 'TSS handler not initialized or does not support signing');
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||||
|
type: 'failed',
|
||||||
|
error: 'TSS handler not initialized',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记签名开始
|
||||||
|
signInProgressSessionId = event.sessionId;
|
||||||
|
|
||||||
|
// 获取 share 数据
|
||||||
|
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
|
||||||
|
if (!share) {
|
||||||
|
debugLog.error('main', 'Failed to get share data');
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||||
|
type: 'failed',
|
||||||
|
error: 'Failed to get share data',
|
||||||
|
});
|
||||||
|
signInProgressSessionId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
|
||||||
|
sessionId: activeCoSignSession.sessionId,
|
||||||
|
partyId: grpcClient?.getPartyId(),
|
||||||
|
partyIndex: activeCoSignSession.partyIndex,
|
||||||
|
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
|
||||||
|
threshold: activeCoSignSession.threshold,
|
||||||
|
messageHash: activeCoSignSession.messageHash.substring(0, 16) + '...',
|
||||||
|
});
|
||||||
|
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await (tssHandler as TSSHandler).participateSign(
|
||||||
|
activeCoSignSession.sessionId,
|
||||||
|
grpcClient?.getPartyId() || '',
|
||||||
|
activeCoSignSession.partyIndex,
|
||||||
|
activeCoSignSession.participants,
|
||||||
|
activeCoSignSession.threshold,
|
||||||
|
activeCoSignSession.messageHash,
|
||||||
|
share.raw_share,
|
||||||
|
activeCoSignSession.sharePassword
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
debugLog.info('tss', 'Sign completed successfully');
|
||||||
|
await handleCoSignComplete(result);
|
||||||
|
} else {
|
||||||
|
debugLog.error('tss', `Sign failed: ${result.error}`);
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession.sessionId}`, {
|
||||||
|
type: 'failed',
|
||||||
|
error: result.error || 'Sign failed',
|
||||||
|
});
|
||||||
|
signInProgressSessionId = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog.error('tss', `Sign error: ${(error as Error).message}`);
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession?.sessionId}`, {
|
||||||
|
type: 'failed',
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
signInProgressSessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Co-Sign 完成 - 保存签名并报告完成
|
||||||
|
async function handleCoSignComplete(result: { success: boolean; signature: Buffer; error?: string }) {
|
||||||
|
if (!activeCoSignSession || !database || !grpcClient) {
|
||||||
|
debugLog.error('main', 'Missing required components for sign completion');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = activeCoSignSession.sessionId;
|
||||||
|
const partyId = grpcClient.getPartyId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signatureHex = result.signature.toString('hex');
|
||||||
|
|
||||||
|
// 1. 更新签名历史
|
||||||
|
database.updateSigningHistory(sessionId, {
|
||||||
|
status: 'completed',
|
||||||
|
signature: signatureHex,
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLog.info('main', `Signature saved: ${signatureHex.substring(0, 32)}...`);
|
||||||
|
|
||||||
|
// 2. 报告完成给 session-coordinator
|
||||||
|
const allCompleted = await grpcClient.reportCompletion(
|
||||||
|
sessionId,
|
||||||
|
partyId || '',
|
||||||
|
result.signature
|
||||||
|
);
|
||||||
|
|
||||||
|
debugLog.info('grpc', `Reported sign completion to session-coordinator, all_completed: ${allCompleted}`);
|
||||||
|
|
||||||
|
// 3. 通知前端
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
|
||||||
|
type: 'completed',
|
||||||
|
signature: signatureHex,
|
||||||
|
allCompleted: allCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 清理活跃会话和幂等性标志
|
||||||
|
activeCoSignSession = null;
|
||||||
|
signInProgressSessionId = null;
|
||||||
|
debugLog.info('main', 'Co-Sign session completed and cleaned up');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
debugLog.error('main', `Failed to handle sign completion: ${error}`);
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
|
||||||
|
type: 'failed',
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
signInProgressSessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 连接并注册到 Message Router
|
// 连接并注册到 Message Router
|
||||||
async function connectAndRegisterToMessageRouter() {
|
async function connectAndRegisterToMessageRouter() {
|
||||||
if (!grpcClient || !database) {
|
if (!grpcClient || !database) {
|
||||||
|
|
@ -688,19 +997,40 @@ async function connectAndRegisterToMessageRouter() {
|
||||||
// 转发事件到前端
|
// 转发事件到前端
|
||||||
mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData);
|
mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData);
|
||||||
|
|
||||||
// 根据事件类型处理
|
// 根据事件类型处理 - 区分 Keygen 和 Co-Sign 会话
|
||||||
|
const isCoSignSession = activeCoSignSession?.sessionId === event.session_id;
|
||||||
|
const isKeygenSession = activeKeygenSession?.sessionId === event.session_id;
|
||||||
|
|
||||||
if (event.event_type === 'all_joined') {
|
if (event.event_type === 'all_joined') {
|
||||||
// 收到 all_joined 事件表示所有参与方都已加入
|
// 收到 all_joined 事件表示所有参与方都已加入
|
||||||
// 此时启动 5 分钟倒计时,在此期间完成 keygen 启动
|
debugLog.info('main', `Received all_joined event for session ${event.session_id}, isCoSign=${isCoSignSession}, isKeygen=${isKeygenSession}`);
|
||||||
debugLog.info('main', `Received all_joined event for session ${event.session_id}, starting 5-minute keygen countdown`);
|
|
||||||
|
|
||||||
// 使用 setImmediate 确保不阻塞事件处理
|
if (isCoSignSession) {
|
||||||
|
// Co-Sign 会话:转发到 cosign 频道并触发签名
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||||
|
setImmediate(() => {
|
||||||
|
checkAndTriggerCoSign(event.session_id);
|
||||||
|
});
|
||||||
|
} else if (isKeygenSession) {
|
||||||
|
// Keygen 会话:启动 5 分钟倒计时
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
checkAndTriggerKeygen(event.session_id);
|
checkAndTriggerKeygen(event.session_id);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else if (event.event_type === 'session_started') {
|
} else if (event.event_type === 'session_started') {
|
||||||
// session_started 事件表示 keygen 可以开始了(所有人已准备就绪)
|
// session_started 事件表示可以开始了
|
||||||
// 直接触发 keygen
|
if (isCoSignSession) {
|
||||||
|
// Co-Sign 会话
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||||
|
await handleCoSignStart({
|
||||||
|
eventType: event.event_type,
|
||||||
|
sessionId: event.session_id,
|
||||||
|
thresholdN: event.threshold_n,
|
||||||
|
thresholdT: event.threshold_t,
|
||||||
|
selectedParties: event.selected_parties,
|
||||||
|
});
|
||||||
|
} else if (isKeygenSession) {
|
||||||
|
// Keygen 会话
|
||||||
await handleSessionStart({
|
await handleSessionStart({
|
||||||
eventType: event.event_type,
|
eventType: event.event_type,
|
||||||
sessionId: event.session_id,
|
sessionId: event.session_id,
|
||||||
|
|
@ -709,6 +1039,12 @@ async function connectAndRegisterToMessageRouter() {
|
||||||
selectedParties: event.selected_parties,
|
selectedParties: event.selected_parties,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (event.event_type === 'participant_joined') {
|
||||||
|
// 参与者加入事件 - 也需要区分会话类型
|
||||||
|
if (isCoSignSession) {
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect/register to Message Router:', (error as Error).message);
|
console.error('Failed to connect/register to Message Router:', (error as Error).message);
|
||||||
|
|
@ -1206,6 +1542,317 @@ function setupIpcHandlers() {
|
||||||
return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com';
|
return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关 IPC 处理器
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 创建 Co-Sign 会话
|
||||||
|
ipcMain.handle('cosign:createSession', async (_event, params: {
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
messageHash: string;
|
||||||
|
initiatorName?: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// 获取当前 party ID
|
||||||
|
const partyId = grpcClient?.getPartyId();
|
||||||
|
if (!partyId) {
|
||||||
|
return { success: false, error: '请先连接到消息路由器' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本地获取 share 信息
|
||||||
|
const share = database?.getShare(params.shareId, params.sharePassword);
|
||||||
|
if (!share) {
|
||||||
|
return { success: false, error: 'Share 不存在或密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析参与者信息
|
||||||
|
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 || '发起者',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.session_id) {
|
||||||
|
return { success: false, error: '创建签名会话失败: 未返回会话ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建签名历史记录
|
||||||
|
database?.createSigningHistory({
|
||||||
|
shareId: params.shareId,
|
||||||
|
sessionId: result.session_id,
|
||||||
|
messageHash: params.messageHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发起方自动加入会话
|
||||||
|
const joinToken = result.join_token;
|
||||||
|
if (joinToken) {
|
||||||
|
console.log('[CO-SIGN] Initiator auto-joining session...');
|
||||||
|
const joinResult = await grpcClient?.joinSession(result.session_id, partyId, joinToken);
|
||||||
|
|
||||||
|
if (joinResult?.success) {
|
||||||
|
// 设置活跃的 Co-Sign 会话信息
|
||||||
|
const signParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
|
||||||
|
|
||||||
|
// 添加发起方
|
||||||
|
signParticipants.push({
|
||||||
|
partyId: partyId,
|
||||||
|
partyIndex: joinResult.party_index,
|
||||||
|
name: params.initiatorName || '发起者',
|
||||||
|
});
|
||||||
|
|
||||||
|
activeCoSignSession = {
|
||||||
|
sessionId: result.session_id,
|
||||||
|
partyIndex: joinResult.party_index,
|
||||||
|
participants: signParticipants,
|
||||||
|
threshold: {
|
||||||
|
t: share.threshold_t,
|
||||||
|
n: share.threshold_n,
|
||||||
|
},
|
||||||
|
walletName: share.wallet_name,
|
||||||
|
messageHash: params.messageHash,
|
||||||
|
shareId: params.shareId,
|
||||||
|
sharePassword: params.sharePassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Initiator active session set:', {
|
||||||
|
sessionId: activeCoSignSession.sessionId,
|
||||||
|
partyIndex: activeCoSignSession.partyIndex,
|
||||||
|
threshold: activeCoSignSession.threshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预订阅消息流
|
||||||
|
if (tssHandler && 'prepareForSign' in tssHandler) {
|
||||||
|
debugLog.info('tss', `Initiator preparing for sign: subscribing to messages for session ${result.session_id}`);
|
||||||
|
(tssHandler as TSSHandler).prepareForSign(result.session_id, partyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查会话状态
|
||||||
|
const sessionStatus = joinResult.session_info?.status;
|
||||||
|
debugLog.info('main', `Initiator JoinSession response: status=${sessionStatus}`);
|
||||||
|
|
||||||
|
if (sessionStatus === 'in_progress') {
|
||||||
|
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
|
||||||
|
setImmediate(async () => {
|
||||||
|
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
|
||||||
|
await handleCoSignStart({
|
||||||
|
eventType: 'session_started',
|
||||||
|
sessionId: result.session_id,
|
||||||
|
thresholdT: share.threshold_t,
|
||||||
|
thresholdN: share.threshold_n,
|
||||||
|
selectedParties: selectedParties,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[CO-SIGN] Initiator failed to join session');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessionId: result.session_id,
|
||||||
|
inviteCode: result.invite_code,
|
||||||
|
walletName: share.wallet_name,
|
||||||
|
expiresAt: result.expires_at,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证 Co-Sign 邀请码
|
||||||
|
ipcMain.handle('cosign:validateInviteCode', async (_event, { code }) => {
|
||||||
|
try {
|
||||||
|
debugLog.info('account', `Validating co-sign invite code: ${code}`);
|
||||||
|
const result = await accountClient?.getSignSessionByInviteCode(code);
|
||||||
|
|
||||||
|
if (!result?.session_id) {
|
||||||
|
return { success: false, error: '无效的邀请码' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sessionInfo: {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
status: result.status,
|
||||||
|
currentParticipants: result.joined_count || 0,
|
||||||
|
parties: result.parties,
|
||||||
|
},
|
||||||
|
joinToken: result.join_token,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
debugLog.error('account', `Failed to validate co-sign invite code: ${(error as Error).message}`);
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加入 Co-Sign 会话
|
||||||
|
ipcMain.handle('cosign:joinSession', async (_event, params: {
|
||||||
|
sessionId: string;
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
joinToken: string;
|
||||||
|
walletName?: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const partyId = grpcClient?.getPartyId();
|
||||||
|
if (!partyId) {
|
||||||
|
return { success: false, error: '请先连接到消息路由器' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 share
|
||||||
|
const share = database?.getShare(params.shareId, params.sharePassword);
|
||||||
|
if (!share) {
|
||||||
|
return { success: false, error: 'Share 不存在或密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
|
||||||
|
|
||||||
|
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
|
||||||
|
if (result?.success) {
|
||||||
|
// 设置活跃的 Co-Sign 会话
|
||||||
|
const participants: Array<{ partyId: string; partyIndex: number; name: string }> = result.other_parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
|
||||||
|
partyId: p.party_id,
|
||||||
|
partyIndex: p.party_index,
|
||||||
|
name: `参与方 ${idx + 1}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
// 添加自己
|
||||||
|
participants.push({
|
||||||
|
partyId: partyId,
|
||||||
|
partyIndex: result.party_index,
|
||||||
|
name: '我',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按 partyIndex 排序
|
||||||
|
participants.sort((a, b) => a.partyIndex - b.partyIndex);
|
||||||
|
|
||||||
|
activeCoSignSession = {
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
partyIndex: result.party_index,
|
||||||
|
participants: participants,
|
||||||
|
threshold: params.threshold,
|
||||||
|
walletName: params.walletName || share.wallet_name,
|
||||||
|
messageHash: params.messageHash,
|
||||||
|
shareId: params.shareId,
|
||||||
|
sharePassword: params.sharePassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[CO-SIGN] Active session set:', {
|
||||||
|
sessionId: activeCoSignSession.sessionId,
|
||||||
|
partyIndex: activeCoSignSession.partyIndex,
|
||||||
|
participantCount: activeCoSignSession.participants.length,
|
||||||
|
threshold: activeCoSignSession.threshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建签名历史记录
|
||||||
|
database?.createSigningHistory({
|
||||||
|
shareId: params.shareId,
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
messageHash: params.messageHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预订阅消息流
|
||||||
|
if (tssHandler && 'prepareForSign' in tssHandler) {
|
||||||
|
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
|
||||||
|
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查会话状态
|
||||||
|
const sessionStatus = result.session_info?.status;
|
||||||
|
debugLog.info('main', `JoinSession response: status=${sessionStatus}`);
|
||||||
|
|
||||||
|
if (sessionStatus === 'in_progress') {
|
||||||
|
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
|
||||||
|
setImmediate(async () => {
|
||||||
|
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
|
||||||
|
await handleCoSignStart({
|
||||||
|
eventType: 'session_started',
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
thresholdT: params.threshold.t,
|
||||||
|
thresholdN: params.threshold.n,
|
||||||
|
selectedParties: selectedParties,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: result };
|
||||||
|
} else {
|
||||||
|
return { success: false, error: '加入会话失败' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取 Co-Sign 会话状态
|
||||||
|
ipcMain.handle('cosign:getSessionStatus', async (_event, { sessionId }) => {
|
||||||
|
try {
|
||||||
|
const result = await accountClient?.getSignSessionStatus(sessionId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
session: {
|
||||||
|
sessionId: result?.session_id,
|
||||||
|
status: result?.status,
|
||||||
|
joinedCount: result?.joined_count,
|
||||||
|
threshold: {
|
||||||
|
t: activeCoSignSession?.threshold?.t || 0,
|
||||||
|
n: activeCoSignSession?.threshold?.n || 0,
|
||||||
|
},
|
||||||
|
participants: result?.parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
|
||||||
|
partyId: p.party_id,
|
||||||
|
partyIndex: p.party_index,
|
||||||
|
name: activeCoSignSession?.participants?.find(ap => ap.partyId === p.party_id)?.name || `参与方 ${idx + 1}`,
|
||||||
|
status: 'ready',
|
||||||
|
})) || [],
|
||||||
|
messageHash: activeCoSignSession?.messageHash || '',
|
||||||
|
walletName: activeCoSignSession?.walletName || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅 Co-Sign 会话事件
|
||||||
|
ipcMain.on('cosign:subscribeSessionEvents', (_event, { sessionId }) => {
|
||||||
|
debugLog.debug('main', `Frontend subscribing to co-sign session events: ${sessionId}`);
|
||||||
|
|
||||||
|
// 获取并发送缓存的事件
|
||||||
|
const cachedEvents = getAndClearCachedEvents(sessionId);
|
||||||
|
if (cachedEvents.length > 0) {
|
||||||
|
debugLog.info('main', `Sending ${cachedEvents.length} cached events to frontend for co-sign session ${sessionId}`);
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
mainWindow?.webContents.send(`cosign:events:${sessionId}`, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 取消订阅 Co-Sign 会话事件
|
||||||
|
ipcMain.on('cosign:unsubscribeSessionEvents', (_event, { sessionId }) => {
|
||||||
|
debugLog.debug('main', `Frontend unsubscribing from co-sign session events: ${sessionId}`);
|
||||||
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Share 存储相关 (SQLite)
|
// Share 存储相关 (SQLite)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,17 @@ export interface GetSignSessionByInviteCodeResponse {
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
parties: SignPartyInfo[];
|
parties: SignPartyInfo[];
|
||||||
joined_count: number;
|
joined_count: number;
|
||||||
|
join_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSignSessionStatusResponse {
|
||||||
|
session_id: string;
|
||||||
|
status: string;
|
||||||
|
threshold_t: number;
|
||||||
|
joined_count: number;
|
||||||
|
parties: SignPartyInfo[];
|
||||||
|
message_hash?: string;
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误响应
|
// 错误响应
|
||||||
|
|
@ -313,6 +324,16 @@ export class AccountClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Sign 会话状态
|
||||||
|
*/
|
||||||
|
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
|
||||||
|
return this.request<GetSignSessionStatusResponse>(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/co-managed/sign/${sessionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 健康检查
|
// 健康检查
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,239 @@ export class TSSHandler extends EventEmitter {
|
||||||
this.messageBuffer = [];
|
this.messageBuffer = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关方法 - 与 Keygen 完全隔离的签名功能
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预订阅签名消息流 - 在 joinSession 后立即调用
|
||||||
|
* 与 prepareForKeygen 类似,确保在其他方开始发送消息时已准备好接收和缓冲
|
||||||
|
*
|
||||||
|
* @param sessionId 签名会话 ID
|
||||||
|
* @param partyId 自己的 party ID
|
||||||
|
*/
|
||||||
|
prepareForSign(sessionId: string, partyId: string): void {
|
||||||
|
// 如果已经为同一个 session 准备过,跳过
|
||||||
|
if (this.isPrepared && this.sessionId === sessionId) {
|
||||||
|
console.log('[TSS-SIGN] Already prepared for same session, skip');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果为不同的 session 准备过,先取消旧的订阅
|
||||||
|
if (this.isPrepared && this.sessionId !== sessionId) {
|
||||||
|
console.log(`[TSS-SIGN] Switching from session ${this.sessionId?.substring(0, 8)}... to ${sessionId.substring(0, 8)}...`);
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
|
this.grpcClient.unsubscribeMessages();
|
||||||
|
this.messageBuffer = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[TSS-SIGN] Preparing for sign: session=${sessionId.substring(0, 8)}..., party=${partyId.substring(0, 8)}...`);
|
||||||
|
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.partyId = partyId;
|
||||||
|
this.isPrepared = true;
|
||||||
|
this.messageBuffer = [];
|
||||||
|
|
||||||
|
// 立即订阅消息流,开始缓冲消息
|
||||||
|
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||||
|
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||||
|
|
||||||
|
console.log('[TSS-SIGN] Message subscription started, buffering enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消签名预订阅
|
||||||
|
*/
|
||||||
|
cancelSignPrepare(): void {
|
||||||
|
if (!this.isPrepared) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TSS-SIGN] Canceling sign prepare');
|
||||||
|
this.isPrepared = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
|
this.grpcClient.unsubscribeMessages();
|
||||||
|
this.sessionId = null;
|
||||||
|
this.partyId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参与 Co-Sign 协议
|
||||||
|
*
|
||||||
|
* @param sessionId 签名会话 ID
|
||||||
|
* @param partyId 自己的 party ID
|
||||||
|
* @param partyIndex 自己在签名参与方中的索引
|
||||||
|
* @param participants 签名参与方列表 (T 个参与方)
|
||||||
|
* @param threshold 阈值配置 { t: 签名阈值, n: keygen时的总参与方数 }
|
||||||
|
* @param messageHash 待签名的消息哈希 (hex 编码)
|
||||||
|
* @param shareData 本地 share 数据 (base64 编码的加密数据)
|
||||||
|
* @param sharePassword share 解密密码
|
||||||
|
*/
|
||||||
|
async participateSign(
|
||||||
|
sessionId: string,
|
||||||
|
partyId: string,
|
||||||
|
partyIndex: number,
|
||||||
|
participants: ParticipantInfo[],
|
||||||
|
threshold: { t: number; n: number },
|
||||||
|
messageHash: string,
|
||||||
|
shareData: string,
|
||||||
|
sharePassword: string
|
||||||
|
): Promise<SignResult> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
throw new Error('TSS protocol already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已经预订阅
|
||||||
|
const wasPrepared = this.isPrepared && this.sessionId === sessionId;
|
||||||
|
const bufferedCount = this.messageBuffer.length;
|
||||||
|
|
||||||
|
console.log(`[TSS-SIGN] Starting sign: wasPrepared=${wasPrepared}, bufferedMessages=${bufferedCount}`);
|
||||||
|
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.partyId = partyId;
|
||||||
|
this.partyIndex = partyIndex;
|
||||||
|
this.participants = participants;
|
||||||
|
this.isRunning = true;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
// 注意:不清空消息缓冲,保留预订阅阶段收到的消息
|
||||||
|
|
||||||
|
// 构建 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();
|
||||||
|
console.log(`[TSS-SIGN] Binary path: ${binaryPath}`);
|
||||||
|
console.log(`[TSS-SIGN] Binary exists: ${fs.existsSync(binaryPath)}`);
|
||||||
|
|
||||||
|
// 构建参与者列表 JSON
|
||||||
|
const participantsJson = JSON.stringify(participants);
|
||||||
|
console.log(`[TSS-SIGN] Participants: ${participantsJson}`);
|
||||||
|
console.log(`[TSS-SIGN] partyIndex=${partyIndex}, threshold=${threshold.t}-of-${threshold.n}`);
|
||||||
|
console.log(`[TSS-SIGN] messageHash=${messageHash.substring(0, 16)}...`);
|
||||||
|
|
||||||
|
// 启动 TSS 子进程 - sign 命令
|
||||||
|
const args = [
|
||||||
|
'sign',
|
||||||
|
'--session-id', sessionId,
|
||||||
|
'--party-id', partyId,
|
||||||
|
'--party-index', partyIndex.toString(),
|
||||||
|
'--threshold-t', threshold.t.toString(),
|
||||||
|
'--threshold-n', threshold.n.toString(),
|
||||||
|
'--participants', participantsJson,
|
||||||
|
'--message-hash', messageHash,
|
||||||
|
'--share-data', shareData,
|
||||||
|
'--password', sharePassword,
|
||||||
|
];
|
||||||
|
console.log(`[TSS-SIGN] Spawning: ${binaryPath} sign ...`);
|
||||||
|
|
||||||
|
this.tssProcess = spawn(binaryPath, args);
|
||||||
|
|
||||||
|
let resultData = '';
|
||||||
|
let stderrData = '';
|
||||||
|
|
||||||
|
// 如果没有预订阅,现在订阅消息
|
||||||
|
if (!wasPrepared) {
|
||||||
|
console.log('[TSS-SIGN] Subscribing to messages (not prepared before)');
|
||||||
|
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||||
|
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||||
|
} else {
|
||||||
|
console.log(`[TSS-SIGN] Using existing subscription, ${bufferedCount} messages buffered`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标准输出 (JSON 消息)
|
||||||
|
this.tssProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
// 收到第一条输出时,标记进程就绪并发送缓冲的消息
|
||||||
|
if (!this.isProcessReady && this.tssProcess?.stdin) {
|
||||||
|
this.isProcessReady = true;
|
||||||
|
console.log(`[TSS-SIGN] Process ready, flushing ${this.messageBuffer.length} buffered messages`);
|
||||||
|
this.flushMessageBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
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-SIGN]', line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理标准错误
|
||||||
|
this.tssProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const errorText = data.toString();
|
||||||
|
stderrData += errorText;
|
||||||
|
console.error('[TSS-SIGN stderr]', errorText);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理进程退出
|
||||||
|
this.tssProcess.on('close', (code) => {
|
||||||
|
const completedSessionId = this.sessionId;
|
||||||
|
this.isRunning = false;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.isPrepared = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
|
this.tssProcess = null;
|
||||||
|
// 清理消息监听器
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
|
|
||||||
|
if (code === 0 && resultData) {
|
||||||
|
try {
|
||||||
|
const result: TSSMessage = JSON.parse(resultData);
|
||||||
|
if (result.payload) {
|
||||||
|
// 成功完成后清理该会话的已处理消息记录
|
||||||
|
if (this.database && completedSessionId) {
|
||||||
|
this.database.clearProcessedMessages(completedSessionId);
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
signature: Buffer.from(result.payload, 'base64'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error(result.error || 'Sign failed: no result data'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Failed to parse sign result: ${e}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMsg = stderrData.trim() || `Sign process exited with code ${code}`;
|
||||||
|
console.error(`[TSS-SIGN] Process failed: code=${code}, stderr=${stderrData}`);
|
||||||
|
reject(new Error(errorMsg));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理进程错误
|
||||||
|
this.tssProcess.on('error', (err) => {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.isPrepared = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
|
this.tssProcess = null;
|
||||||
|
// 清理消息监听器
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.isPrepared = false;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消正在进行的协议
|
* 取消正在进行的协议
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
register: (partyId: string, role: string) =>
|
register: (partyId: string, role: string) =>
|
||||||
ipcRenderer.invoke('grpc:register', { partyId, role }),
|
ipcRenderer.invoke('grpc:register', { partyId, role }),
|
||||||
|
|
||||||
// 签名相关
|
// 签名相关 (旧版 - persistent 签名使用)
|
||||||
validateSigningSession: (code: string) =>
|
validateSigningSession: (code: string) =>
|
||||||
ipcRenderer.invoke('grpc:validateSigningSession', { code }),
|
ipcRenderer.invoke('grpc:validateSigningSession', { code }),
|
||||||
|
|
||||||
|
|
@ -88,6 +88,53 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关 - 全新的多方协作签名 API
|
||||||
|
// ===========================================================================
|
||||||
|
cosign: {
|
||||||
|
// 创建 Co-Sign 会话
|
||||||
|
createSession: (params: {
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
messageHash: string;
|
||||||
|
initiatorName?: string;
|
||||||
|
}) => ipcRenderer.invoke('cosign:createSession', params),
|
||||||
|
|
||||||
|
// 验证邀请码
|
||||||
|
validateInviteCode: (code: string) =>
|
||||||
|
ipcRenderer.invoke('cosign:validateInviteCode', { code }),
|
||||||
|
|
||||||
|
// 加入 Co-Sign 会话
|
||||||
|
joinSession: (params: {
|
||||||
|
sessionId: string;
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
joinToken: string;
|
||||||
|
walletName?: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
}) => ipcRenderer.invoke('cosign:joinSession', params),
|
||||||
|
|
||||||
|
// 获取会话状态
|
||||||
|
getSessionStatus: (sessionId: string) =>
|
||||||
|
ipcRenderer.invoke('cosign:getSessionStatus', { sessionId }),
|
||||||
|
|
||||||
|
// 订阅会话事件
|
||||||
|
subscribeSessionEvents: (sessionId: string, callback: (event: unknown) => void) => {
|
||||||
|
const channel = `cosign:events:${sessionId}`;
|
||||||
|
const listener = (_event: unknown, data: unknown) => callback(data);
|
||||||
|
eventSubscriptions.set(channel, listener);
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
ipcRenderer.send('cosign:subscribeSessionEvents', { sessionId });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(channel, listener);
|
||||||
|
eventSubscriptions.delete(channel);
|
||||||
|
ipcRenderer.send('cosign:unsubscribeSessionEvents', { sessionId });
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Account 服务相关 (HTTP API)
|
// Account 服务相关 (HTTP API)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import Create from './pages/Create';
|
||||||
import Session from './pages/Session';
|
import Session from './pages/Session';
|
||||||
import Sign from './pages/Sign';
|
import Sign from './pages/Sign';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
// Co-Sign 页面
|
||||||
|
import CoSignCreate from './pages/CoSignCreate';
|
||||||
|
import CoSignJoin from './pages/CoSignJoin';
|
||||||
|
import CoSignSession from './pages/CoSignSession';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [startupComplete, setStartupComplete] = useState(false);
|
const [startupComplete, setStartupComplete] = useState(false);
|
||||||
|
|
@ -37,12 +41,20 @@ function App() {
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
{/* Keygen 路由 */}
|
||||||
<Route path="/join" element={<Join />} />
|
<Route path="/join" element={<Join />} />
|
||||||
<Route path="/join/:inviteCode" element={<Join />} />
|
<Route path="/join/:inviteCode" element={<Join />} />
|
||||||
<Route path="/create" element={<Create />} />
|
<Route path="/create" element={<Create />} />
|
||||||
<Route path="/session/:sessionId" element={<Session />} />
|
<Route path="/session/:sessionId" element={<Session />} />
|
||||||
|
{/* 旧版签名路由 (persistent) */}
|
||||||
<Route path="/sign" element={<Sign />} />
|
<Route path="/sign" element={<Sign />} />
|
||||||
<Route path="/sign/:sessionId" element={<Sign />} />
|
<Route path="/sign/:sessionId" element={<Sign />} />
|
||||||
|
{/* Co-Sign 路由 */}
|
||||||
|
<Route path="/cosign/create" element={<CoSignCreate />} />
|
||||||
|
<Route path="/cosign/join" element={<CoSignJoin />} />
|
||||||
|
<Route path="/cosign/join/:inviteCode" element={<CoSignJoin />} />
|
||||||
|
<Route path="/cosign/session/:sessionId" element={<CoSignSession />} />
|
||||||
|
{/* 设置 */}
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styles from './Create.module.css';
|
||||||
|
|
||||||
|
interface Share {
|
||||||
|
id: string;
|
||||||
|
walletName: string;
|
||||||
|
publicKey: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCoSignResult {
|
||||||
|
success: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
inviteCode?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoSignCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [shares, setShares] = useState<Share[]>([]);
|
||||||
|
const [selectedShareId, setSelectedShareId] = useState('');
|
||||||
|
const [sharePassword, setSharePassword] = useState('');
|
||||||
|
const [messageHash, setMessageHash] = useState('');
|
||||||
|
const [participantName, setParticipantName] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<CreateCoSignResult | null>(null);
|
||||||
|
const [step, setStep] = useState<'config' | 'creating' | 'created'>('config');
|
||||||
|
|
||||||
|
// 加载本地 shares
|
||||||
|
useEffect(() => {
|
||||||
|
const loadShares = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.storage.listShares();
|
||||||
|
// 兼容不同返回格式
|
||||||
|
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
|
||||||
|
setShares(shareList);
|
||||||
|
if (shareList.length > 0) {
|
||||||
|
setSelectedShareId(shareList[0].id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load shares:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadShares();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateSession = async () => {
|
||||||
|
if (!selectedShareId) {
|
||||||
|
setError('请选择一个钱包');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!messageHash.trim()) {
|
||||||
|
setError('请输入待签名的消息哈希');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-fA-F]{64}$/.test(messageHash.trim())) {
|
||||||
|
setError('消息哈希必须是 64 位十六进制字符串 (32 字节)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!participantName.trim()) {
|
||||||
|
setParticipantName('发起者');
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('creating');
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createResult = await window.electronAPI.cosign.createSession({
|
||||||
|
shareId: selectedShareId,
|
||||||
|
sharePassword: sharePassword,
|
||||||
|
messageHash: messageHash.trim().toLowerCase(),
|
||||||
|
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(`/cosign/session/${result.sessionId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedShare = shares.find(s => s.id === selectedShareId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h1 className={styles.title}>发起多方签名</h1>
|
||||||
|
<p className={styles.subtitle}>选择钱包并输入待签名的消息哈希</p>
|
||||||
|
|
||||||
|
{step === 'config' && (
|
||||||
|
<div className={styles.form}>
|
||||||
|
{/* 签名说明 */}
|
||||||
|
<div className={styles.infoBox}>
|
||||||
|
<div className={styles.infoIcon}>i</div>
|
||||||
|
<div className={styles.infoContent}>
|
||||||
|
<strong>多方协作签名说明</strong>
|
||||||
|
<ul className={styles.infoList}>
|
||||||
|
<li><strong>选择钱包</strong>: 使用已创建的共管钱包进行签名</li>
|
||||||
|
<li><strong>消息哈希</strong>: 待签名数据的 SHA256 哈希值</li>
|
||||||
|
<li><strong>阈值签名</strong>: 需要足够数量的参与方共同完成</li>
|
||||||
|
<li><strong>安全性</strong>: 私钥份额永不离开本地设备</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选择钱包 */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>选择钱包</label>
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<p className={styles.hint}>暂无可用钱包,请先创建或加入共管钱包</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedShareId}
|
||||||
|
onChange={(e) => setSelectedShareId(e.target.value)}
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{shares.map(share => (
|
||||||
|
<option key={share.id} value={share.id}>
|
||||||
|
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{selectedShare && (
|
||||||
|
<p className={styles.hint}>
|
||||||
|
公钥: {selectedShare.publicKey.substring(0, 16)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 钱包密码 */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>钱包密码 (可选)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={sharePassword}
|
||||||
|
onChange={(e) => setSharePassword(e.target.value)}
|
||||||
|
placeholder="如果设置了密码,请输入"
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息哈希 */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>消息哈希 (Hex)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageHash}
|
||||||
|
onChange={(e) => setMessageHash(e.target.value)}
|
||||||
|
placeholder="64位十六进制字符串,如: a1b2c3d4..."
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
待签名数据的 SHA256 哈希值 (32 字节 = 64 个十六进制字符)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 参与者名称 */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>您的名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={participantName}
|
||||||
|
onChange={(e) => setParticipantName(e.target.value)}
|
||||||
|
placeholder="输入您的名称(其他参与者可见)"
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleCreateSession}
|
||||||
|
disabled={isLoading || shares.length === 0}
|
||||||
|
>
|
||||||
|
创建签名会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'creating' && (
|
||||||
|
<div className={styles.creating}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>正在创建签名会话...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'created' && result && (
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.successIcon}>OK</div>
|
||||||
|
<h3 className={styles.successTitle}>签名会话创建成功</h3>
|
||||||
|
|
||||||
|
<div className={styles.inviteSection}>
|
||||||
|
<label className={styles.label}>邀请码</label>
|
||||||
|
<div className={styles.inviteCodeWrapper}>
|
||||||
|
<code className={styles.inviteCode}>{result.inviteCode}</code>
|
||||||
|
<button
|
||||||
|
className={styles.copyButton}
|
||||||
|
onClick={handleCopyInviteCode}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
将此邀请码分享给其他参与方,他们可以使用此码加入签名
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleGoToSession}
|
||||||
|
>
|
||||||
|
进入签名会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import styles from './Join.module.css';
|
||||||
|
|
||||||
|
interface Share {
|
||||||
|
id: string;
|
||||||
|
walletName: string;
|
||||||
|
publicKey: string;
|
||||||
|
sessionId: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
keygenSessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
status: string;
|
||||||
|
currentParticipants: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidateResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
sessionInfo?: SessionInfo;
|
||||||
|
joinToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoSignJoin() {
|
||||||
|
const { inviteCode } = useParams<{ inviteCode?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [code, setCode] = useState(inviteCode || '');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||||||
|
const [joinToken, setJoinToken] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<'input' | 'select_share' | 'joining'>('input');
|
||||||
|
|
||||||
|
// Share 选择相关
|
||||||
|
const [shares, setShares] = useState<Share[]>([]);
|
||||||
|
const [selectedShareId, setSelectedShareId] = useState('');
|
||||||
|
const [sharePassword, setSharePassword] = useState('');
|
||||||
|
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
|
||||||
|
|
||||||
|
// 加载本地 shares
|
||||||
|
useEffect(() => {
|
||||||
|
const loadShares = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.storage.listShares();
|
||||||
|
// 兼容不同返回格式
|
||||||
|
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
|
||||||
|
setShares(shareList);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load shares:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadShares();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 如果 URL 中有邀请码,自动验证
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteCode) {
|
||||||
|
handleValidateCode(inviteCode);
|
||||||
|
}
|
||||||
|
}, [inviteCode]);
|
||||||
|
|
||||||
|
// 自动选择匹配的 share
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionInfo && shares.length > 0 && !selectedShareId) {
|
||||||
|
// 尝试找到匹配的 share(基于 keygen session ID)
|
||||||
|
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||||||
|
if (matchingShare) {
|
||||||
|
setSelectedShareId(matchingShare.id);
|
||||||
|
} else if (shares.length === 1) {
|
||||||
|
// 如果只有一个 share,自动选择
|
||||||
|
setSelectedShareId(shares[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessionInfo, shares, selectedShareId]);
|
||||||
|
|
||||||
|
// 自动加入
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
step === 'select_share' &&
|
||||||
|
sessionInfo &&
|
||||||
|
joinToken &&
|
||||||
|
selectedShareId &&
|
||||||
|
!autoJoinAttempted &&
|
||||||
|
!isLoading
|
||||||
|
) {
|
||||||
|
// 找到匹配的 share 且未尝试过自动加入,则自动加入
|
||||||
|
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
|
||||||
|
if (matchingShare && matchingShare.id === selectedShareId) {
|
||||||
|
setAutoJoinAttempted(true);
|
||||||
|
handleJoinSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [step, sessionInfo, joinToken, selectedShareId, autoJoinAttempted, isLoading, shares]);
|
||||||
|
|
||||||
|
const handleValidateCode = async (codeToValidate: string) => {
|
||||||
|
if (!codeToValidate.trim()) {
|
||||||
|
setError('请输入邀请码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: ValidateResult = await window.electronAPI.cosign.validateInviteCode(codeToValidate);
|
||||||
|
if (result.success && result.sessionInfo) {
|
||||||
|
setSessionInfo(result.sessionInfo);
|
||||||
|
if (result.joinToken) {
|
||||||
|
setJoinToken(result.joinToken);
|
||||||
|
}
|
||||||
|
setStep('select_share');
|
||||||
|
} else {
|
||||||
|
setError(result.error || '无效的邀请码');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('验证邀请码失败,请检查网络连接');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinSession = async () => {
|
||||||
|
if (!sessionInfo) {
|
||||||
|
setError('会话信息不完整');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joinToken) {
|
||||||
|
setError('未获取到加入令牌,请重新验证邀请码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedShareId) {
|
||||||
|
setError('请选择一个钱包');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('joining');
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cosign.joinSession({
|
||||||
|
sessionId: sessionInfo.sessionId,
|
||||||
|
shareId: selectedShareId,
|
||||||
|
sharePassword: sharePassword,
|
||||||
|
joinToken: joinToken,
|
||||||
|
walletName: sessionInfo.walletName,
|
||||||
|
messageHash: sessionInfo.messageHash,
|
||||||
|
threshold: sessionInfo.threshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate(`/cosign/session/${sessionInfo.sessionId}`);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '加入会话失败');
|
||||||
|
setStep('select_share');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('加入会话失败,请重试');
|
||||||
|
setStep('select_share');
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedShare = shares.find(s => s.id === selectedShareId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h1 className={styles.title}>加入多方签名</h1>
|
||||||
|
<p className={styles.subtitle}>输入邀请码加入签名会话</p>
|
||||||
|
|
||||||
|
{step === 'input' && (
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>邀请码</label>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="粘贴签名邀请码"
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.pasteButton}
|
||||||
|
onClick={handlePaste}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
粘贴
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={() => handleValidateCode(code)}
|
||||||
|
disabled={isLoading || !code.trim()}
|
||||||
|
>
|
||||||
|
{isLoading ? '验证中...' : '下一步'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'select_share' && sessionInfo && (
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.sessionInfo}>
|
||||||
|
<h3 className={styles.sessionTitle}>签名会话信息</h3>
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>钱包名称</span>
|
||||||
|
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>签名阈值</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>消息哈希</span>
|
||||||
|
<span className={styles.infoValue} style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||||||
|
{sessionInfo.messageHash.substring(0, 16)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span className={styles.infoLabel}>当前参与者</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{sessionInfo.currentParticipants} / {sessionInfo.threshold.t}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选择本地 share */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>选择本地钱包</label>
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<p className={styles.hint}>暂无可用钱包,请先创建或加入共管钱包</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedShareId}
|
||||||
|
onChange={(e) => setSelectedShareId(e.target.value)}
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{shares.map(share => (
|
||||||
|
<option key={share.id} value={share.id}>
|
||||||
|
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
|
||||||
|
{share.sessionId === sessionInfo.keygenSessionId ? ' [匹配]' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{selectedShare && (
|
||||||
|
<p className={styles.hint}>
|
||||||
|
公钥: {selectedShare.publicKey.substring(0, 16)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 钱包密码 */}
|
||||||
|
<div className={styles.inputGroup}>
|
||||||
|
<label className={styles.label}>钱包密码 (可选)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={sharePassword}
|
||||||
|
onChange={(e) => setSharePassword(e.target.value)}
|
||||||
|
placeholder="如果设置了密码,请输入"
|
||||||
|
className={styles.input}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => {
|
||||||
|
setStep('input');
|
||||||
|
setSessionInfo(null);
|
||||||
|
setJoinToken(null);
|
||||||
|
setSelectedShareId('');
|
||||||
|
setError(null);
|
||||||
|
setAutoJoinAttempted(false);
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleJoinSession}
|
||||||
|
disabled={isLoading || !selectedShareId}
|
||||||
|
>
|
||||||
|
{isLoading ? '加入中...' : '加入签名'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'joining' && (
|
||||||
|
<div className={styles.joining}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>正在加入签名会话...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
sessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||||
|
participants: Participant[];
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
signature?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CoSignSession() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSessionStatus = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.cosign.getSessionStatus(sessionId);
|
||||||
|
if (result.success && result.session) {
|
||||||
|
setSession({
|
||||||
|
sessionId: result.session.sessionId || sessionId,
|
||||||
|
walletName: result.session.walletName || '',
|
||||||
|
messageHash: result.session.messageHash || '',
|
||||||
|
threshold: result.session.threshold || { t: 0, n: 0 },
|
||||||
|
status: mapStatus(result.session.status),
|
||||||
|
participants: result.session.participants || [],
|
||||||
|
currentRound: 0,
|
||||||
|
totalRounds: 9, // GG20 签名有 9 轮
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError(result.error || '获取会话状态失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('获取会话状态失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
// 映射后端状态到前端状态
|
||||||
|
const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
case 'waiting':
|
||||||
|
return 'waiting';
|
||||||
|
case 'all_joined':
|
||||||
|
case 'ready':
|
||||||
|
return 'ready';
|
||||||
|
case 'in_progress':
|
||||||
|
case 'signing':
|
||||||
|
return 'processing';
|
||||||
|
case 'completed':
|
||||||
|
return 'completed';
|
||||||
|
case 'failed':
|
||||||
|
case 'expired':
|
||||||
|
return 'failed';
|
||||||
|
default:
|
||||||
|
return 'waiting';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessionStatus();
|
||||||
|
|
||||||
|
// 订阅会话事件
|
||||||
|
const unsubscribe = window.electronAPI.cosign.subscribeSessionEvents(
|
||||||
|
sessionId!,
|
||||||
|
(event: any) => {
|
||||||
|
console.log('[CoSignSession] Received event:', event);
|
||||||
|
|
||||||
|
if (event.type === 'participant_joined') {
|
||||||
|
setSession(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
participants: event.participant
|
||||||
|
? [...prev.participants, event.participant]
|
||||||
|
: prev.participants,
|
||||||
|
} : null);
|
||||||
|
// 刷新状态
|
||||||
|
fetchSessionStatus();
|
||||||
|
} else if (event.type === 'status_changed' || event.type === 'all_joined') {
|
||||||
|
setSession(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
status: event.status ? mapStatus(event.status) : prev.status,
|
||||||
|
} : null);
|
||||||
|
// 刷新状态
|
||||||
|
fetchSessionStatus();
|
||||||
|
} else if (event.type === 'progress') {
|
||||||
|
setSession(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
status: 'processing',
|
||||||
|
currentRound: event.round || prev.currentRound,
|
||||||
|
totalRounds: event.totalRounds || prev.totalRounds,
|
||||||
|
} : null);
|
||||||
|
} else if (event.type === 'completed') {
|
||||||
|
setSession(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
status: 'completed',
|
||||||
|
signature: event.signature,
|
||||||
|
} : null);
|
||||||
|
} else if (event.type === 'failed' || event.type === 'sign_start_timeout') {
|
||||||
|
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 'OK';
|
||||||
|
case 'processing': return '*';
|
||||||
|
case 'completed': return 'OK';
|
||||||
|
case 'failed': return 'X';
|
||||||
|
default: return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopySignature = async () => {
|
||||||
|
if (session?.signature) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(session.signature);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>加载签名会话信息...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !session) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.error}>
|
||||||
|
<div className={styles.errorIcon}>!</div>
|
||||||
|
<h3>加载失败</h3>
|
||||||
|
<p>{error || '无法获取会话信息'}</p>
|
||||||
|
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.title}>{session.walletName || '多方签名'}</h1>
|
||||||
|
<p className={styles.sessionId}>会话 ID: {session.sessionId?.substring(0, 16)}...</p>
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
|
||||||
|
{getStatusText(session.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
{/* 消息哈希 */}
|
||||||
|
{session.messageHash && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>待签名消息</h3>
|
||||||
|
<code style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--background-color)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
fontSize: '12px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
{session.messageHash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进度部分 */}
|
||||||
|
{session.status === 'processing' && (
|
||||||
|
<div className={styles.progress}>
|
||||||
|
<div className={styles.progressHeader}>
|
||||||
|
<span>签名进度</span>
|
||||||
|
<span>{session.currentRound} / {session.totalRounds}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={styles.progressFill}
|
||||||
|
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 参与方列表 */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
参与方 ({(session.participants || []).length} / {session.threshold?.t || 0})
|
||||||
|
</h3>
|
||||||
|
<div className={styles.participantList}>
|
||||||
|
{(session.participants || []).map((participant, index) => (
|
||||||
|
<div key={participant.partyId || index} className={styles.participant}>
|
||||||
|
<div className={styles.participantInfo}>
|
||||||
|
<span className={styles.participantIndex}>#{index + 1}</span>
|
||||||
|
<span className={styles.participantName}>{participant.name || `参与方 ${index + 1}`}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
|
||||||
|
{getParticipantStatusIcon(participant.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: Math.max(0, (session.threshold?.t || 0) - (session.participants || []).length) }).map((_, index) => (
|
||||||
|
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
|
||||||
|
<div className={styles.participantInfo}>
|
||||||
|
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
|
||||||
|
<span className={styles.participantName}>等待加入...</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.participantStatus}>...</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 阈值信息 */}
|
||||||
|
{session.threshold && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>签名阈值</h3>
|
||||||
|
<div className={styles.thresholdInfo}>
|
||||||
|
<span className={styles.thresholdBadge}>
|
||||||
|
{session.threshold.t}-of-{session.threshold.n}
|
||||||
|
</span>
|
||||||
|
<span className={styles.thresholdText}>
|
||||||
|
需要 {session.threshold.t} 个参与方共同签名
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 完成状态 */}
|
||||||
|
{session.status === 'completed' && session.signature && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>签名结果</h3>
|
||||||
|
<div className={styles.publicKeyWrapper}>
|
||||||
|
<code className={styles.publicKey}>{session.signature}</code>
|
||||||
|
<button className={styles.copyButton} onClick={handleCopySignature}>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={styles.successMessage}>
|
||||||
|
OK 签名已成功生成
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 失败状态 */}
|
||||||
|
{session.status === 'failed' && session.error && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.failureMessage}>
|
||||||
|
<span className={styles.failureIcon}>!</span>
|
||||||
|
<span>{session.error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -410,6 +410,93 @@ interface KavaHealthCheckResult {
|
||||||
// Electron API 接口
|
// Electron API 接口
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Co-Sign 相关类型
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
interface CoSignSessionInfo {
|
||||||
|
sessionId: string;
|
||||||
|
keygenSessionId: string;
|
||||||
|
walletName: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
status: string;
|
||||||
|
currentParticipants: number;
|
||||||
|
parties?: Array<{ party_id: string; party_index: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCoSignSessionParams {
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
messageHash: string;
|
||||||
|
initiatorName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCoSignSessionResult {
|
||||||
|
success: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
inviteCode?: string;
|
||||||
|
walletName?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidateCoSignInviteCodeResult {
|
||||||
|
success: boolean;
|
||||||
|
sessionInfo?: CoSignSessionInfo;
|
||||||
|
joinToken?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinCoSignSessionParams {
|
||||||
|
sessionId: string;
|
||||||
|
shareId: string;
|
||||||
|
sharePassword: string;
|
||||||
|
joinToken: string;
|
||||||
|
walletName?: string;
|
||||||
|
messageHash: string;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinCoSignSessionResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetCoSignSessionStatusResult {
|
||||||
|
success: boolean;
|
||||||
|
session?: {
|
||||||
|
sessionId: string;
|
||||||
|
status: string;
|
||||||
|
joinedCount: number;
|
||||||
|
threshold: { t: number; n: number };
|
||||||
|
participants: Array<{
|
||||||
|
partyId: string;
|
||||||
|
partyIndex: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
messageHash: string;
|
||||||
|
walletName: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoSignSessionEvent {
|
||||||
|
type: 'participant_joined' | 'status_changed' | 'all_joined' | 'progress' | 'completed' | 'failed' | 'sign_start_timeout';
|
||||||
|
participant?: {
|
||||||
|
partyId: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
status?: string;
|
||||||
|
round?: number;
|
||||||
|
totalRounds?: number;
|
||||||
|
signature?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
// Account 服务相关 (HTTP API)
|
// Account 服务相关 (HTTP API)
|
||||||
account: {
|
account: {
|
||||||
|
|
@ -521,6 +608,15 @@ interface ElectronAPI {
|
||||||
unsubscribeLogs: () => void;
|
unsubscribeLogs: () => void;
|
||||||
log: (level: string, source: string, message: string) => void;
|
log: (level: string, source: string, message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Co-Sign 相关 (多方协作签名)
|
||||||
|
cosign: {
|
||||||
|
createSession: (params: CreateCoSignSessionParams) => Promise<CreateCoSignSessionResult>;
|
||||||
|
validateInviteCode: (code: string) => Promise<ValidateCoSignInviteCodeResult>;
|
||||||
|
joinSession: (params: JoinCoSignSessionParams) => Promise<JoinCoSignSessionResult>;
|
||||||
|
getSessionStatus: (sessionId: string) => Promise<GetCoSignSessionStatusResult>;
|
||||||
|
subscribeSessionEvents: (sessionId: string, callback: (event: CoSignSessionEvent) => void) => () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ require github.com/bnb-chain/tss-lib/v2 v2.0.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
|
||||||
|
github.com/btcsuite/btcd v0.23.4 // indirect
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
|
||||||
|
github.com/btcsuite/btcutil v1.0.2 // indirect
|
||||||
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // 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/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
|
@ -20,6 +23,7 @@ require (
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap v1.26.0 // indirect
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
|
golang.org/x/crypto v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.15.0 // indirect
|
golang.org/x/sys v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQE
|
||||||
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
|
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
|
||||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||||
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
|
||||||
|
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
|
||||||
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
|
||||||
|
|
@ -15,9 +16,11 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY
|
||||||
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
|
||||||
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||||
|
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||||
|
|
@ -144,6 +147,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,674 @@
|
||||||
|
// integration_test.go - Integration test for the complete co-sign flow
|
||||||
|
// Tests: session creation, joining, waiting, events, and signing
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||||
|
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Backend service URLs (from docker-compose.windows.yml)
|
||||||
|
accountServiceURL = "http://localhost:4000"
|
||||||
|
grpcRouterAddr = "localhost:50051"
|
||||||
|
)
|
||||||
|
|
||||||
|
// API Response types for co-managed keygen
|
||||||
|
type CreateCoManagedSessionRequest struct {
|
||||||
|
WalletName string `json:"wallet_name"`
|
||||||
|
ThresholdT int `json:"threshold_t"`
|
||||||
|
ThresholdN int `json:"threshold_n"`
|
||||||
|
InitiatorPartyID string `json:"initiator_party_id"`
|
||||||
|
InitiatorName string `json:"initiator_name,omitempty"`
|
||||||
|
PersistentCount int `json:"persistent_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCoManagedSessionResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
WalletName string `json:"wallet_name"`
|
||||||
|
ThresholdT int `json:"threshold_t"`
|
||||||
|
ThresholdN int `json:"threshold_n"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
JoinToken string `json:"join_token"`
|
||||||
|
PartyID string `json:"party_id"`
|
||||||
|
PartyIndex int `json:"party_index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types for co-managed sign
|
||||||
|
type CreateSignSessionRequest struct {
|
||||||
|
KeygenSessionID string `json:"keygen_session_id"`
|
||||||
|
WalletName string `json:"wallet_name"`
|
||||||
|
MessageHash string `json:"message_hash"`
|
||||||
|
Parties []SignPartyInfo `json:"parties"`
|
||||||
|
ThresholdT int `json:"threshold_t"`
|
||||||
|
InitiatorName string `json:"initiator_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateSignSessionResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
KeygenSessionID string `json:"keygen_session_id"`
|
||||||
|
MessageHash string `json:"message_hash"`
|
||||||
|
ThresholdT int `json:"threshold_t"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
JoinToken string `json:"join_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSignSessionResponse struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
KeygenSessionID string `json:"keygen_session_id"`
|
||||||
|
WalletName string `json:"wallet_name"`
|
||||||
|
MessageHash string `json:"message_hash"`
|
||||||
|
ThresholdT int `json:"threshold_t"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
Parties []SignPartyInfo `json:"parties"`
|
||||||
|
JoinedCount int `json:"joined_count"`
|
||||||
|
JoinToken string `json:"join_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignPartyInfo struct {
|
||||||
|
PartyID string `json:"party_id"`
|
||||||
|
PartyIndex int `json:"party_index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccountServiceHealth tests if account service is available
|
||||||
|
func TestAccountServiceHealth(t *testing.T) {
|
||||||
|
resp, err := http.Get(accountServiceURL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to connect to account service: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("Account service unhealthy, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Account service is healthy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateSignSession tests creating a new sign session
|
||||||
|
func TestCreateSignSession(t *testing.T) {
|
||||||
|
// This requires an existing keygen session ID
|
||||||
|
// For testing, we'll use a mock one
|
||||||
|
keygenSessionID := "test-keygen-session-" + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
|
||||||
|
reqBody := CreateSignSessionRequest{
|
||||||
|
KeygenSessionID: keygenSessionID,
|
||||||
|
MessageHash: messageHash,
|
||||||
|
InitiatorName: "test-initiator",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, _ := json.Marshal(reqBody)
|
||||||
|
resp, err := http.Post(
|
||||||
|
accountServiceURL+"/api/v1/co-managed/sign",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(jsonBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create sign session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||||
|
t.Logf("Note: This test requires an existing keygen session in the database")
|
||||||
|
t.Skipf("Sign session creation returned status %d (expected existing keygen session)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result CreateSignSessionResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Session created: ID=%s, InviteCode=%s", result.SessionID, result.InviteCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSessionByInviteCode tests retrieving session info by invite code
|
||||||
|
func TestGetSessionByInviteCode(t *testing.T) {
|
||||||
|
// This would need a valid invite code from a real session
|
||||||
|
inviteCode := "TEST123"
|
||||||
|
|
||||||
|
resp, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + inviteCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Get session response (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
t.Skip("No session found with invite code (expected for test)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoManagedKeygenSessionFlow tests the keygen session creation and join flow
|
||||||
|
func TestCoManagedKeygenSessionFlow(t *testing.T) {
|
||||||
|
t.Log("=== Co-Managed Keygen Session Flow Test ===")
|
||||||
|
|
||||||
|
// Step 1: Create a keygen session
|
||||||
|
t.Log("Step 1: Creating keygen session...")
|
||||||
|
reqBody := CreateCoManagedSessionRequest{
|
||||||
|
WalletName: "test-wallet-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
|
ThresholdT: 1, // 2-of-3: t=1 means t+1=2 signers needed
|
||||||
|
ThresholdN: 3,
|
||||||
|
InitiatorPartyID: "test-initiator-party",
|
||||||
|
InitiatorName: "Test User",
|
||||||
|
PersistentCount: 0, // No server parties for this test
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, _ := json.Marshal(reqBody)
|
||||||
|
resp, err := http.Post(
|
||||||
|
accountServiceURL+"/api/v1/co-managed/sessions",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(jsonBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||||
|
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var createResp CreateCoManagedSessionResponse
|
||||||
|
if err := json.Unmarshal(body, &createResp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Session created: ID=%s, InviteCode=%s", createResp.SessionID, createResp.InviteCode)
|
||||||
|
|
||||||
|
// Step 2: Get session by invite code
|
||||||
|
t.Log("Step 2: Getting session by invite code...")
|
||||||
|
resp2, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/by-invite-code/" + createResp.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
|
body2, _ := io.ReadAll(resp2.Body)
|
||||||
|
t.Logf("Get session response (status %d): %s", resp2.StatusCode, string(body2))
|
||||||
|
|
||||||
|
if resp2.StatusCode != 200 {
|
||||||
|
t.Fatalf("Failed to get session by invite code, status: %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get session status
|
||||||
|
t.Log("Step 3: Getting session status...")
|
||||||
|
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/" + createResp.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get session status: %v", err)
|
||||||
|
}
|
||||||
|
defer resp3.Body.Close()
|
||||||
|
|
||||||
|
body3, _ := io.ReadAll(resp3.Body)
|
||||||
|
t.Logf("Session status response (status %d): %s", resp3.StatusCode, string(body3))
|
||||||
|
|
||||||
|
if resp3.StatusCode != 200 {
|
||||||
|
t.Fatalf("Failed to get session status, status: %d", resp3.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("Keygen session flow test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoManagedSignSessionFlow tests the full sign session flow
|
||||||
|
func TestCoManagedSignSessionFlow(t *testing.T) {
|
||||||
|
t.Log("=== Co-Managed Sign Session Flow Test ===")
|
||||||
|
|
||||||
|
// First, create a keygen session to get a valid keygen_session_id
|
||||||
|
t.Log("Step 1: Creating keygen session...")
|
||||||
|
keygenReq := CreateCoManagedSessionRequest{
|
||||||
|
WalletName: "test-wallet-for-sign-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
|
ThresholdT: 1,
|
||||||
|
ThresholdN: 3,
|
||||||
|
InitiatorPartyID: "test-party-0",
|
||||||
|
InitiatorName: "Test User",
|
||||||
|
PersistentCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, _ := json.Marshal(keygenReq)
|
||||||
|
resp, err := http.Post(
|
||||||
|
accountServiceURL+"/api/v1/co-managed/sessions",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(jsonBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create keygen session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Keygen session response (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||||
|
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keygenResp CreateCoManagedSessionResponse
|
||||||
|
if err := json.Unmarshal(body, &keygenResp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse keygen response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create a sign session using the keygen session ID
|
||||||
|
// Note: For threshold T, we need T+1 parties to sign
|
||||||
|
// Backend validates that parties.length >= threshold_t + 1
|
||||||
|
t.Log("Step 2: Creating sign session...")
|
||||||
|
signReq := CreateSignSessionRequest{
|
||||||
|
KeygenSessionID: keygenResp.SessionID,
|
||||||
|
WalletName: keygenReq.WalletName,
|
||||||
|
MessageHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||||
|
ThresholdT: 1, // Threshold T=1 means T+1=2 parties needed to sign
|
||||||
|
Parties: []SignPartyInfo{
|
||||||
|
{PartyID: "test-party-0", PartyIndex: 0},
|
||||||
|
{PartyID: "test-party-1", PartyIndex: 1},
|
||||||
|
},
|
||||||
|
InitiatorName: "Test User",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody2, _ := json.Marshal(signReq)
|
||||||
|
resp2, err := http.Post(
|
||||||
|
accountServiceURL+"/api/v1/co-managed/sign",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(jsonBody2),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create sign session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
|
||||||
|
body2, _ := io.ReadAll(resp2.Body)
|
||||||
|
t.Logf("Sign session response (status %d): %s", resp2.StatusCode, string(body2))
|
||||||
|
|
||||||
|
if resp2.StatusCode != 200 && resp2.StatusCode != 201 {
|
||||||
|
t.Fatalf("Failed to create sign session, status: %d", resp2.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var signResp CreateSignSessionResponse
|
||||||
|
if err := json.Unmarshal(body2, &signResp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse sign response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Sign session created: ID=%s, InviteCode=%s", signResp.SessionID, signResp.InviteCode)
|
||||||
|
|
||||||
|
// Step 3: Get sign session by invite code
|
||||||
|
t.Log("Step 3: Getting sign session by invite code...")
|
||||||
|
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + signResp.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get sign session: %v", err)
|
||||||
|
}
|
||||||
|
defer resp3.Body.Close()
|
||||||
|
|
||||||
|
body3, _ := io.ReadAll(resp3.Body)
|
||||||
|
t.Logf("Get sign session response (status %d): %s", resp3.StatusCode, string(body3))
|
||||||
|
|
||||||
|
if resp3.StatusCode != 200 {
|
||||||
|
t.Fatalf("Failed to get sign session by invite code, status: %d", resp3.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var getSignResp GetSignSessionResponse
|
||||||
|
if err := json.Unmarshal(body3, &getSignResp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse get sign session response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Sign session status: %s, JoinedCount: %d, ThresholdT: %d",
|
||||||
|
getSignResp.Status, getSignResp.JoinedCount, getSignResp.ThresholdT)
|
||||||
|
|
||||||
|
t.Log("Sign session flow test passed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullSignFlow tests the complete signing flow with mock data
|
||||||
|
func TestFullSignFlow(t *testing.T) {
|
||||||
|
t.Log("=== Full Co-Sign Flow Test ===")
|
||||||
|
|
||||||
|
// Step 1: Generate mock key shares (simulating keygen result)
|
||||||
|
// NOTE: In tss-lib, threshold parameter means "t" where you need t+1 parties to sign.
|
||||||
|
// So for 2-of-3, we use threshold=1 (meaning 1+1=2 parties needed to sign)
|
||||||
|
t.Log("Step 1: Generating mock key shares for 2-of-3 scheme...")
|
||||||
|
thresholdT := 1 // t value: need t+1=2 parties to sign
|
||||||
|
totalN := 3 // total parties
|
||||||
|
shares, err := generateMockKeyShares(thresholdT, totalN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate key shares: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Generated %d key shares", len(shares))
|
||||||
|
|
||||||
|
// Step 2: Prepare signing parameters
|
||||||
|
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
|
||||||
|
sessionID := fmt.Sprintf("test-sign-session-%d", time.Now().UnixNano())
|
||||||
|
password := "test-password"
|
||||||
|
|
||||||
|
// Step 3: Start signing with 2 parties (index 0 and 1)
|
||||||
|
// For tss-party.exe, we pass the signing threshold (t+1=2) and total (n=3)
|
||||||
|
t.Log("Step 2: Starting sign process with 2 parties...")
|
||||||
|
signingParties := []int{0, 1}
|
||||||
|
|
||||||
|
// Pass thresholdT+1=2 as the signing threshold to tss-party.exe
|
||||||
|
signature, err := runSigningProcess(shares, signingParties, messageHash, sessionID, password, thresholdT+1, totalN, t)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Signing failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Step 3: Signing complete!")
|
||||||
|
t.Logf("Signature (hex): %x", signature)
|
||||||
|
t.Logf("Signature (base64): %s", base64.StdEncoding.EncodeToString(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMockKeyShares generates key shares using tss-lib directly
|
||||||
|
func generateMockKeyShares(threshold, total int) ([]*keygen.LocalPartySaveData, error) {
|
||||||
|
fmt.Println("[Keygen] Starting key generation for", total, "parties with threshold", threshold)
|
||||||
|
|
||||||
|
partyIDs := make([]*tss.PartyID, total)
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
partyIDs[i] = tss.NewPartyID(
|
||||||
|
fmt.Sprintf("party-%d", i),
|
||||||
|
fmt.Sprintf("party-%d", i),
|
||||||
|
big.NewInt(int64(i+1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedPartyIDs := tss.SortPartyIDs(partyIDs)
|
||||||
|
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||||
|
|
||||||
|
outChannels := make([]chan tss.Message, total)
|
||||||
|
endChannels := make([]chan *keygen.LocalPartySaveData, total)
|
||||||
|
parties := make([]tss.Party, total)
|
||||||
|
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
outChannels[i] = make(chan tss.Message, total*20)
|
||||||
|
endChannels[i] = make(chan *keygen.LocalPartySaveData, 1)
|
||||||
|
params := tss.NewParameters(tss.S256(), peerCtx, sortedPartyIDs[i], total, threshold)
|
||||||
|
parties[i] = keygen.NewLocalParty(params, outChannels[i], endChannels[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start all parties
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errChan := make(chan error, total)
|
||||||
|
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := parties[idx].Start(); err != nil {
|
||||||
|
errChan <- fmt.Errorf("party %d start error: %w", idx, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]*keygen.LocalPartySaveData, total)
|
||||||
|
var resultsMu sync.Mutex
|
||||||
|
resultCount := 0
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
// Message routing
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
select {
|
||||||
|
case msg := <-outChannels[i]:
|
||||||
|
wire, _, _ := msg.WireBytes()
|
||||||
|
if msg.IsBroadcast() {
|
||||||
|
for j := 0; j < total; j++ {
|
||||||
|
if j != i {
|
||||||
|
go func(destIdx int) {
|
||||||
|
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), true)
|
||||||
|
parties[destIdx].Update(parsed)
|
||||||
|
}(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, to := range msg.GetTo() {
|
||||||
|
for j := 0; j < total; j++ {
|
||||||
|
if sortedPartyIDs[j].Id == to.Id {
|
||||||
|
go func(destIdx int) {
|
||||||
|
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), false)
|
||||||
|
parties[destIdx].Update(parsed)
|
||||||
|
}(j)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case result := <-endChannels[i]:
|
||||||
|
resultsMu.Lock()
|
||||||
|
results[i] = result
|
||||||
|
resultCount++
|
||||||
|
fmt.Printf("[Keygen] Party %d completed\n", i)
|
||||||
|
if resultCount == total {
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
resultsMu.Unlock()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
fmt.Println("[Keygen] All parties completed successfully")
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
return nil, fmt.Errorf("keygen timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errChan:
|
||||||
|
return nil, err
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSigningProcess runs the signing process using tss-party.exe
|
||||||
|
func runSigningProcess(
|
||||||
|
shares []*keygen.LocalPartySaveData,
|
||||||
|
signingPartyIndices []int,
|
||||||
|
messageHash, sessionID, password string,
|
||||||
|
thresholdT, thresholdN int,
|
||||||
|
t *testing.T,
|
||||||
|
) ([]byte, error) {
|
||||||
|
// Build participants
|
||||||
|
participants := make([]Participant, len(signingPartyIndices))
|
||||||
|
for i, idx := range signingPartyIndices {
|
||||||
|
participants[i] = Participant{
|
||||||
|
PartyID: fmt.Sprintf("party-%d", idx),
|
||||||
|
PartyIndex: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
participantsJSON, _ := json.Marshal(participants)
|
||||||
|
|
||||||
|
// Prepare encrypted shares
|
||||||
|
encryptedShares := make([]string, len(signingPartyIndices))
|
||||||
|
for i, idx := range signingPartyIndices {
|
||||||
|
shareBytes, _ := json.Marshal(shares[idx])
|
||||||
|
encrypted := encryptShare(shareBytes, password)
|
||||||
|
encryptedShares[i] = base64.StdEncoding.EncodeToString(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tss-party executable
|
||||||
|
exePath := "./tss-party.exe"
|
||||||
|
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||||
|
exePath = "./tss-party"
|
||||||
|
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("tss-party executable not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Using executable: %s", exePath)
|
||||||
|
|
||||||
|
// Start processes
|
||||||
|
processes := make([]*exec.Cmd, len(signingPartyIndices))
|
||||||
|
stdinPipes := make([]io.WriteCloser, len(signingPartyIndices))
|
||||||
|
stdoutPipes := make([]io.ReadCloser, len(signingPartyIndices))
|
||||||
|
|
||||||
|
for i, idx := range signingPartyIndices {
|
||||||
|
args := []string{
|
||||||
|
"sign",
|
||||||
|
"-session-id", sessionID,
|
||||||
|
"-party-id", fmt.Sprintf("party-%d", idx),
|
||||||
|
"-party-index", fmt.Sprintf("%d", idx),
|
||||||
|
"-threshold-t", fmt.Sprintf("%d", thresholdT),
|
||||||
|
"-threshold-n", fmt.Sprintf("%d", thresholdN),
|
||||||
|
"-participants", string(participantsJSON),
|
||||||
|
"-message-hash", messageHash,
|
||||||
|
"-share-data", encryptedShares[i],
|
||||||
|
"-password", password,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("[Party %d] Starting with args: sign -session-id %s -party-id party-%d ...", idx, sessionID, idx)
|
||||||
|
|
||||||
|
cmd := exec.Command(exePath, args...)
|
||||||
|
stdin, _ := cmd.StdinPipe()
|
||||||
|
stdout, _ := cmd.StdoutPipe()
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
processes[i] = cmd
|
||||||
|
stdinPipes[i] = stdin
|
||||||
|
stdoutPipes[i] = stdout
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start party %d: %w", idx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message routing between parties
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]byte, len(signingPartyIndices))
|
||||||
|
errors := make([]error, len(signingPartyIndices))
|
||||||
|
|
||||||
|
// Create a mutex for stdin writes
|
||||||
|
stdinMutexes := make([]*sync.Mutex, len(signingPartyIndices))
|
||||||
|
for i := range stdinMutexes {
|
||||||
|
stdinMutexes[i] = &sync.Mutex{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range signingPartyIndices {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(partyIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
scanner := bufio.NewScanner(stdoutPipes[partyIdx])
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
||||||
|
t.Logf("[Party %d] Invalid JSON: %s", signingPartyIndices[partyIdx], line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "progress":
|
||||||
|
t.Logf("[Party %d] Progress: round %d/%d", signingPartyIndices[partyIdx], msg.Round, msg.TotalRounds)
|
||||||
|
|
||||||
|
case "outgoing":
|
||||||
|
// tss-party.exe outputs "outgoing" messages that need to be routed to other parties
|
||||||
|
t.Logf("[Party %d] Outgoing message (broadcast=%v, toParties=%v)", signingPartyIndices[partyIdx], msg.IsBroadcast, msg.ToParties)
|
||||||
|
// Route to other parties
|
||||||
|
for j := range signingPartyIndices {
|
||||||
|
if j != partyIdx {
|
||||||
|
// If not broadcast, check if this party is in the ToParties list
|
||||||
|
if !msg.IsBroadcast && len(msg.ToParties) > 0 {
|
||||||
|
targetPartyID := fmt.Sprintf("party-%d", signingPartyIndices[j])
|
||||||
|
found := false
|
||||||
|
for _, to := range msg.ToParties {
|
||||||
|
if to == targetPartyID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tss-party.exe expects "incoming" messages
|
||||||
|
msgToSend := Message{
|
||||||
|
Type: "incoming",
|
||||||
|
IsBroadcast: msg.IsBroadcast,
|
||||||
|
Payload: msg.Payload,
|
||||||
|
FromPartyIndex: signingPartyIndices[partyIdx],
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msgToSend)
|
||||||
|
|
||||||
|
stdinMutexes[j].Lock()
|
||||||
|
stdinPipes[j].Write(append(data, '\n'))
|
||||||
|
stdinMutexes[j].Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "result":
|
||||||
|
t.Logf("[Party %d] Got result!", signingPartyIndices[partyIdx])
|
||||||
|
signature, _ := base64.StdEncoding.DecodeString(msg.Payload)
|
||||||
|
results[partyIdx] = signature
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
t.Logf("[Party %d] Error: %s", signingPartyIndices[partyIdx], msg.Error)
|
||||||
|
errors[partyIdx] = fmt.Errorf("party error: %s", msg.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
t.Logf("[Party %d] Scanner error: %v", signingPartyIndices[partyIdx], err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for processes to complete
|
||||||
|
for i, cmd := range processes {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
t.Logf("[Party %d] Process exit error: %v", signingPartyIndices[i], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
for i, err := range errors {
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("party %d error: %w", signingPartyIndices[i], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first result
|
||||||
|
for _, result := range results {
|
||||||
|
if result != nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no signature received")
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -18,7 +19,9 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/bnb-chain/tss-lib/v2/common"
|
||||||
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
|
||||||
|
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
|
||||||
"github.com/bnb-chain/tss-lib/v2/tss"
|
"github.com/bnb-chain/tss-lib/v2/tss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -118,16 +121,85 @@ func runKeygen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSign() {
|
func runSign() {
|
||||||
// TODO: Implement signing
|
// Parse sign flags
|
||||||
sendError("Signing not implemented yet")
|
fs := flag.NewFlagSet("sign", 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 (total parties in keygen)")
|
||||||
|
participantsJSON := fs.String("participants", "[]", "Participants JSON array")
|
||||||
|
messageHash := fs.String("message-hash", "", "Message hash to sign (hex encoded)")
|
||||||
|
shareData := fs.String("share-data", "", "Encrypted share data (base64 encoded)")
|
||||||
|
password := fs.String("password", "", "Password to decrypt share")
|
||||||
|
|
||||||
|
if err := fs.Parse(os.Args[2:]); err != nil {
|
||||||
|
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
|
||||||
|
sendError("Missing required parameters")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *messageHash == "" {
|
||||||
|
sendError("Missing message hash")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *shareData == "" {
|
||||||
|
sendError("Missing share data")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: For signing, participant count equals threshold T (not N)
|
||||||
|
// because only T parties participate in signing
|
||||||
|
if len(participants) != *thresholdT {
|
||||||
|
sendError(fmt.Sprintf("Participant count mismatch: got %d, expected %d (threshold T)", len(participants), *thresholdT))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sign protocol
|
||||||
|
result, err := executeSign(
|
||||||
|
*sessionID,
|
||||||
|
*partyID,
|
||||||
|
*partyIndex,
|
||||||
|
*thresholdT,
|
||||||
|
*thresholdN,
|
||||||
|
participants,
|
||||||
|
*messageHash,
|
||||||
|
*shareData,
|
||||||
|
*password,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sendError(fmt.Sprintf("Sign failed: %v", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send result
|
||||||
|
sendSignResult(result.Signature, result.RecoveryID, *partyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
type keygenResult struct {
|
type keygenResult struct {
|
||||||
PublicKey []byte
|
PublicKey []byte
|
||||||
EncryptedShare []byte
|
EncryptedShare []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type signResult struct {
|
||||||
|
Signature []byte
|
||||||
|
RecoveryID int
|
||||||
|
}
|
||||||
|
|
||||||
func executeKeygen(
|
func executeKeygen(
|
||||||
sessionID, partyID string,
|
sessionID, partyID string,
|
||||||
partyIndex, thresholdT, thresholdN int,
|
partyIndex, thresholdT, thresholdN int,
|
||||||
|
|
@ -404,3 +476,230 @@ func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
|
||||||
data, _ := json.Marshal(msg)
|
data, _ := json.Marshal(msg)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendSignResult(signature []byte, recoveryID int, partyIndex int) {
|
||||||
|
msg := Message{
|
||||||
|
Type: "result",
|
||||||
|
Payload: base64.StdEncoding.EncodeToString(signature),
|
||||||
|
PartyIndex: partyIndex,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
|
||||||
|
// Match the encryption format: first 32 bytes are password hash, rest is data
|
||||||
|
if len(encryptedData) < 32 {
|
||||||
|
return nil, fmt.Errorf("encrypted data too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password (simple check - matches encryptShare)
|
||||||
|
expectedHash := hashPassword(password)
|
||||||
|
actualHash := encryptedData[:32]
|
||||||
|
|
||||||
|
// Simple comparison
|
||||||
|
match := true
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
if expectedHash[i] != actualHash[i] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
return nil, fmt.Errorf("incorrect password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedData[32:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeSign(
|
||||||
|
sessionID, partyID string,
|
||||||
|
partyIndex, thresholdT, thresholdN int,
|
||||||
|
participants []Participant,
|
||||||
|
messageHashHex string,
|
||||||
|
shareDataBase64 string,
|
||||||
|
password string,
|
||||||
|
) (*signResult, 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()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Decode and decrypt share data
|
||||||
|
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode share data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shareBytes, err := decryptShare(encryptedShare, password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt share: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse keygen save data
|
||||||
|
var keygenData keygen.LocalPartySaveData
|
||||||
|
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse keygen data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode message hash
|
||||||
|
messageHash, err := hex.DecodeString(messageHashHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode message hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messageHash) != 32 {
|
||||||
|
return nil, fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBigInt := new(big.Int).SetBytes(messageHash)
|
||||||
|
|
||||||
|
// Create TSS party IDs for signing participants
|
||||||
|
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
|
||||||
|
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
|
||||||
|
//
|
||||||
|
// The keygenData.Ks contains the public keys for all N parties from keygen.
|
||||||
|
// We need to create party IDs that match the original keygen party structure,
|
||||||
|
// but only include the T parties that are participating in this signing session.
|
||||||
|
|
||||||
|
// Create party IDs only for the signing participants
|
||||||
|
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
|
||||||
|
var selfTSSID *tss.PartyID
|
||||||
|
|
||||||
|
for _, p := range participants {
|
||||||
|
// Use the keygen key at this party's index
|
||||||
|
// The party key in tss-lib uses the key's big.Int representation
|
||||||
|
partyKey := tss.NewPartyID(
|
||||||
|
p.PartyID,
|
||||||
|
fmt.Sprintf("party-%d", p.PartyIndex),
|
||||||
|
big.NewInt(int64(p.PartyIndex+1)),
|
||||||
|
)
|
||||||
|
tssPartyIDs = append(tssPartyIDs, partyKey)
|
||||||
|
if p.PartyID == partyID {
|
||||||
|
selfTSSID = partyKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selfTSSID == nil {
|
||||||
|
return nil, fmt.Errorf("self party not found in participants")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort party IDs (important for tss-lib)
|
||||||
|
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
|
||||||
|
|
||||||
|
// Create peer context and parameters
|
||||||
|
// For signing with T parties from an N-party keygen:
|
||||||
|
// - The peer context contains only the T signing parties
|
||||||
|
// - threshold parameter should be T-1 (since we need T parties to sign, threshold = T-1)
|
||||||
|
peerCtx := tss.NewPeerContext(sortedPartyIDs)
|
||||||
|
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT-1)
|
||||||
|
|
||||||
|
// Create channels
|
||||||
|
outCh := make(chan tss.Message, thresholdT*10)
|
||||||
|
endCh := make(chan *common.SignatureData, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// Create local party for signing
|
||||||
|
localParty := signing.NewLocalParty(msgBigInt, params, keygenData, outCh, endCh)
|
||||||
|
|
||||||
|
// Build party index map for incoming messages
|
||||||
|
partyIndexMap := make(map[int]*tss.PartyID)
|
||||||
|
for _, p := range sortedPartyIDs {
|
||||||
|
for _, orig := range participants {
|
||||||
|
if orig.PartyID == p.Id {
|
||||||
|
partyIndexMap[orig.PartyIndex] = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// 增加 buffer 大小到 1MB
|
||||||
|
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
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 - GG20 signing has 9 rounds
|
||||||
|
totalRounds := 9
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
return nil, err
|
||||||
|
case sigData := <-endCh:
|
||||||
|
// Signing completed successfully
|
||||||
|
sendProgress(totalRounds, totalRounds)
|
||||||
|
|
||||||
|
// Construct signature in DER format or raw R||S format
|
||||||
|
// sigData contains R, S, and recovery ID
|
||||||
|
rBytes := sigData.R
|
||||||
|
sBytes := sigData.S
|
||||||
|
|
||||||
|
// Create raw signature: R (32 bytes) || S (32 bytes)
|
||||||
|
signature := make([]byte, 64)
|
||||||
|
copy(signature[32-len(rBytes):32], rBytes)
|
||||||
|
copy(signature[64-len(sBytes):64], sBytes)
|
||||||
|
|
||||||
|
// Recovery ID for Ethereum-style signatures
|
||||||
|
recoveryID := int(sigData.SignatureRecovery[0])
|
||||||
|
|
||||||
|
return &signResult{
|
||||||
|
Signature: signature,
|
||||||
|
RecoveryID: recoveryID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue