feat(service-party-app): implement TSS signing protocol

- Add sign command to tss-party.exe with GG20 9-round signing protocol
- Add participateSign() method to TSSHandler for TypeScript integration
- Add grpc:executeSign IPC handler in main.ts
- Add kava_evm chain config for EVM address derivation
- Fix ArrayBuffer type handling in Home.tsx export
- Add E2E test for keygen + signing flow (tss_party_e2e_test.go)

The signing implementation:
- Uses keygen share data (LocalPartySaveData) for signing
- Supports threshold signing (t-of-n with subset of parties)
- Returns signature (R || S), recovery ID for ecrecover
- Verified with ECDSA signature verification in E2E test

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-30 04:21:16 -08:00
parent 2e718aecfa
commit bc78ab3fac
10 changed files with 1179 additions and 25 deletions

View File

@ -24,7 +24,7 @@ const USE_MOCK_TSS = process.env.USE_MOCK_TSS === 'true';
let debugLogEnabled = false;
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
type LogSource = 'main' | 'grpc' | 'tss' | 'account' | 'renderer';
type LogSource = 'main' | 'grpc' | 'tss' | 'sign' | 'account' | 'renderer';
function sendDebugLog(level: LogLevel, source: LogSource, message: string) {
if (debugLogEnabled && mainWindow) {
@ -1080,7 +1080,7 @@ function setupIpcHandlers() {
}
});
// gRPC - 加入签名会话
// gRPC - 加入签名会话并执行签名协议
ipcMain.handle('grpc:joinSigningSession', async (_event, params) => {
try {
// 从本地 SQLite 获取 share 数据
@ -1089,19 +1089,121 @@ function setupIpcHandlers() {
return { success: false, error: 'Share not found or incorrect password' };
}
// 验证 keygen_session_id 匹配
if (params.keygenSessionId && share.session_id !== params.keygenSessionId) {
return { success: false, error: 'Share does not match the keygen session' };
}
// 加入签名会话 (通过 gRPC)
// TODO: 实际加入会话逻辑需要使用 gRPC client
// 这里先返回成功,表示验证通过
const joinResult = await grpcClient?.joinSession(
params.sessionId,
share.party_id,
params.joinToken
);
if (!joinResult?.success) {
return { success: false, error: 'Failed to join signing session' };
}
return {
success: true,
partyId: share.party_id,
partyIndex: share.party_index,
sessionInfo: joinResult.session_info,
otherParties: joinResult.other_parties,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 执行签名协议
ipcMain.handle('grpc:executeSign', async (_event, params: {
sessionId: string;
shareId: string;
password: string;
messageHash: string; // hex encoded
participants: Array<{ partyId: string; partyIndex: number }>;
threshold: { t: number; n: number };
}) => {
try {
debugLog.info('sign', `Starting sign protocol for session ${params.sessionId}`);
// 从本地 SQLite 获取 share 数据
const share = database?.getShare(params.shareId, params.password);
if (!share) {
return { success: false, error: 'Share not found or incorrect password' };
}
// 验证 partyId 是否在 participants 中
const myParticipant = params.participants.find(p => p.partyId === share.party_id);
if (!myParticipant) {
return { success: false, error: 'Party not found in participants list' };
}
// 解析 raw_share (这是加密后的 TSS save data)
// raw_share 格式: base64(32-byte password hash + JSON save data)
const rawShareBytes = Buffer.from(share.raw_share, 'base64');
if (rawShareBytes.length <= 32) {
return { success: false, error: 'Invalid share data' };
}
// 跳过前 32 字节的 password hash取后面的 JSON 数据
const shareData = rawShareBytes.subarray(32);
// 转换 messageHash 从 hex 到 Buffer
const messageHashBuffer = Buffer.from(params.messageHash, 'hex');
if (messageHashBuffer.length !== 32) {
return { success: false, error: 'Invalid message hash length (must be 32 bytes)' };
}
debugLog.info('sign', `Executing TSS sign protocol: partyId=${share.party_id.substring(0, 8)}..., partyIndex=${myParticipant.partyIndex}`);
// 执行签名协议 (使用类型断言,因为 MockTSSHandler 没有实现 participateSign)
if (!tssHandler || !('participateSign' in tssHandler)) {
return { success: false, error: 'TSS handler does not support signing' };
}
const result = await (tssHandler as TSSHandler).participateSign(
params.sessionId,
share.party_id,
myParticipant.partyIndex,
params.participants,
params.threshold,
messageHashBuffer,
shareData
);
if (!result?.success) {
return { success: false, error: result?.error || 'Signing failed' };
}
debugLog.info('sign', `Sign completed successfully`);
// 保存签名历史
const history = database?.createSigningHistory({
shareId: params.shareId,
sessionId: params.sessionId,
messageHash: params.messageHash,
});
if (history) {
database?.updateSigningHistory(history.id, {
status: 'completed',
signature: result.signature.toString('hex'),
});
}
return {
success: true,
signature: result.signature.toString('hex'),
r: result.r,
s: result.s,
recoveryId: result.recoveryId,
};
} catch (error) {
debugLog.error('sign', `Sign failed: ${(error as Error).message}`);
return { success: false, error: (error as Error).message };
}
});
// ===========================================================================
// Account 服务相关 (HTTP API)
// ===========================================================================
@ -1222,8 +1324,9 @@ function setupIpcHandlers() {
participants: share.participants || [],
}, password);
// 自动派生 Kava 地址
// 自动派生地址 (Kava Cosmos + Kava EVM)
if (saved && share.publicKey) {
// 派生 Kava Cosmos 地址
try {
const kavaAddress = addressDerivationService.deriveAddress(share.publicKey, 'kava');
database?.saveDerivedAddress({
@ -1236,6 +1339,20 @@ function setupIpcHandlers() {
} catch (err) {
console.error('Failed to derive Kava address:', err);
}
// 派生 Kava EVM 地址 (0x 格式,与 Ethereum 地址相同)
try {
const evmAddress = addressDerivationService.deriveAddress(share.publicKey, 'ethereum');
database?.saveDerivedAddress({
shareId: saved.id,
chain: 'kava_evm',
derivationPath: evmAddress.derivationPath,
address: evmAddress.address,
publicKeyHex: share.publicKey,
});
} catch (err) {
console.error('Failed to derive Kava EVM address:', err);
}
}
return { success: true, data: saved };
@ -1606,6 +1723,18 @@ function setupIpcHandlers() {
return result.canceled ? null : result.filePath;
});
// 写入文件(用于导出备份)
ipcMain.handle('file:write', async (_event, { filePath, data }) => {
try {
// data 可能是 Buffer 或 Uint8Array需要转换
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
fs.writeFileSync(filePath, buffer);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===========================================================================
// 调试相关
// ===========================================================================

View File

@ -21,6 +21,13 @@ export const CHAIN_CONFIGS: Record<string, ChainConfig> = {
curve: 'secp256k1',
derivationPath: "m/44'/459'/0'/0/0",
},
kava_evm: {
name: 'Kava EVM',
prefix: '0x',
coinType: 60,
curve: 'secp256k1',
derivationPath: "m/44'/60'/0'/0/0",
},
cosmos: {
name: 'Cosmos Hub',
prefix: 'cosmos',
@ -263,7 +270,8 @@ export class AddressDerivationService {
let address: string;
if (chain === 'ethereum') {
if (chain === 'ethereum' || chain === 'kava_evm') {
// EVM 兼容地址 (0x 格式)
address = deriveEthereumAddress(publicKeyHex);
} else if (config.curve === 'ed25519') {
address = deriveEd25519Address(publicKeyHex, config.prefix);

View File

@ -19,6 +19,9 @@ export interface KeygenResult {
export interface SignResult {
success: boolean;
signature: Buffer;
r: string; // hex encoded
s: string; // hex encoded
recoveryId: number;
error?: string;
}
@ -26,7 +29,7 @@ export interface SignResult {
* TSS
*/
interface TSSMessage {
type: 'outgoing' | 'result' | 'error' | 'progress';
type: 'outgoing' | 'result' | 'sign_result' | 'error' | 'progress';
isBroadcast?: boolean;
toParties?: string[];
payload?: string; // base64 encoded
@ -36,6 +39,11 @@ interface TSSMessage {
round?: number;
totalRounds?: number;
error?: string;
// Signing result fields
signature?: string; // base64 encoded
r?: string; // hex encoded
s?: string; // hex encoded
recoveryId?: number;
}
/**
@ -477,6 +485,172 @@ export class TSSHandler extends EventEmitter {
this.messageBuffer = [];
}
/**
* Sign
*
* @param sessionId ID
* @param partyId party ID
* @param partyIndex party index ( keygen)
* @param participants ( keygen )
* @param threshold keygen
* @param messageHash (32 bytes)
* @param shareData keygen share (JSON bytes)
*/
async participateSign(
sessionId: string,
partyId: string,
partyIndex: number,
participants: ParticipantInfo[],
threshold: { t: number; n: number },
messageHash: Buffer,
shareData: Buffer
): Promise<SignResult> {
if (this.isRunning) {
throw new Error('TSS protocol already running');
}
console.log(`[TSS] Starting sign: session=${sessionId.substring(0, 8)}..., party=${partyId.substring(0, 8)}...`);
console.log(`[TSS] Sign participants: ${participants.length}, threshold=${threshold.t}-of-${threshold.n}`);
this.sessionId = sessionId;
this.partyId = partyId;
this.partyIndex = partyIndex;
this.participants = participants;
this.isRunning = true;
this.isProcessReady = false;
this.isPrepared = true;
this.messageBuffer = [];
// 构建 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] Binary path: ${binaryPath}`);
// 构建参与者列表 JSON
const participantsJson = JSON.stringify(participants);
// 启动 TSS 子进程
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.toString('base64'),
'--share-data', shareData.toString('base64'),
];
console.log(`[TSS] Spawning sign process`);
this.tssProcess = spawn(binaryPath, args);
let resultData = '';
let stderrData = '';
// 订阅消息
console.log('[TSS] Subscribing to messages for signing');
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
this.grpcClient.subscribeMessages(sessionId, partyId);
// 处理标准输出 (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 === 'sign_result') {
resultData = line;
}
} catch {
// 非 JSON 输出,记录日志
console.log('[TSS]', line);
}
}
});
// 处理标准错误
this.tssProcess.stderr?.on('data', (data: Buffer) => {
const errorText = data.toString();
stderrData += errorText;
console.error('[TSS 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.signature && result.r && result.s !== undefined) {
// 成功完成后清理该会话的已处理消息记录
if (this.database && completedSessionId) {
this.database.clearProcessedMessages(completedSessionId);
}
resolve({
success: true,
signature: Buffer.from(result.signature, 'base64'),
r: result.r,
s: result.s,
recoveryId: result.recoveryId || 0,
});
} else {
reject(new Error(result.error || 'Signing failed: no result data'));
}
} catch (e) {
reject(new Error(`Failed to parse signing result: ${e}`));
}
} else {
const errorMsg = stderrData.trim() || `Signing 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);
}
});
}
/**
*
*/

View File

@ -71,8 +71,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
sessionId: string;
shareId: string;
password: string;
keygenSessionId?: string;
joinToken?: string;
}) => ipcRenderer.invoke('grpc:joinSigningSession', params),
executeSign: (params: {
sessionId: string;
shareId: string;
password: string;
messageHash: string; // hex encoded
participants: Array<{ partyId: string; partyIndex: number }>;
threshold: { t: number; n: number };
}) => ipcRenderer.invoke('grpc:executeSign', params),
subscribeSigningProgress: (sessionId: string, callback: (event: unknown) => void) => {
const channel = `signing:progress:${sessionId}`;
const listener = (_event: unknown, data: unknown) => callback(data);
@ -251,6 +262,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('dialog:saveFile', { defaultPath, filters }),
},
// ===========================================================================
// 文件操作相关
// ===========================================================================
file: {
write: (filePath: string, data: Uint8Array) =>
ipcRenderer.invoke('file:write', { filePath, data }),
},
// ===========================================================================
// 调试相关
// ===========================================================================

View File

@ -11,9 +11,7 @@ interface ShareItem {
threshold: { t: number; n: number };
createdAt: string;
lastUsedAt?: string;
metadata: {
participants: Array<{ partyId: string; name: string }>;
};
participants?: Array<{ partyId: string; name: string }>;
}
interface ShareWithAddress extends ShareItem {
@ -89,15 +87,14 @@ export default function Home() {
const result = await window.electronAPI.storage.exportShare(id, password);
if (result.success && result.data) {
// 通过 IPC 写入文件
const blob = new Blob([result.data], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `share-backup-${id.slice(0, 8)}.dat`;
a.click();
URL.revokeObjectURL(url);
alert('备份文件导出成功!');
// 使用 file:write IPC 写入文件到用户选择的路径
const dataArray = result.data instanceof ArrayBuffer ? new Uint8Array(result.data) : result.data;
const writeResult = await window.electronAPI.file.write(savePath, dataArray);
if (writeResult.success) {
alert('备份文件导出成功!');
} else {
alert('写入文件失败: ' + (writeResult.error || '未知错误'));
}
} else {
alert('导出失败: ' + (result.error || '未知错误'));
}
@ -221,7 +218,7 @@ export default function Home() {
<div className={styles.infoRow}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{(share.metadata?.participants || []).length}
{(share.participants || []).length || share.threshold.n}
</span>
</div>
<div className={styles.infoRow}>

View File

@ -191,12 +191,37 @@ interface JoinSigningSessionParams {
sessionId: string;
shareId: string;
password: string;
keygenSessionId?: string;
joinToken?: string;
}
interface JoinSigningSessionResult {
success: boolean;
partyId?: string;
partyIndex?: number;
sessionInfo?: {
threshold_t?: number;
threshold_n?: number;
};
otherParties?: Array<{ party_id: string; party_index: number }>;
error?: string;
}
interface ExecuteSignParams {
sessionId: string;
shareId: string;
password: string;
messageHash: string; // hex encoded
participants: Array<{ partyId: string; partyIndex: number }>;
threshold: { t: number; n: number };
}
interface ExecuteSignResult {
success: boolean;
signature?: string; // hex encoded
r?: string; // hex encoded
s?: string; // hex encoded
recoveryId?: number;
error?: string;
}
@ -447,6 +472,7 @@ interface ElectronAPI {
// 签名相关
validateSigningSession: (code: string) => Promise<ValidateSigningSessionResult>;
joinSigningSession: (params: JoinSigningSessionParams) => Promise<JoinSigningSessionResult>;
executeSign: (params: ExecuteSignParams) => Promise<ExecuteSignResult>;
subscribeSigningProgress: (sessionId: string, callback: (event: SigningProgressEvent) => void) => () => void;
};
@ -513,6 +539,11 @@ interface ElectronAPI {
saveFile: (defaultPath?: string, filters?: { name: string; extensions: string[] }[]) => Promise<string | null>;
};
// 文件操作相关
file: {
write: (filePath: string, data: Uint8Array) => Promise<{ success: boolean; error?: string }>;
};
// 调试相关
debug?: {
subscribeLogs: (callback: (event: unknown, data: { level: string; source: string; message: string }) => void) => void;

View File

@ -2,11 +2,18 @@ module github.com/rwadurian/mpc-system/services/service-party-app/tss-party
go 1.21
require github.com/bnb-chain/tss-lib/v2 v2.0.2
require (
github.com/bnb-chain/tss-lib/v2 v2.0.2
github.com/btcsuite/btcd/btcec/v2 v2.3.2
github.com/stretchr/testify v1.8.4
)
require (
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd v0.23.4 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@ -18,10 +25,13 @@ require (
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.uber.org/multierr v1.11.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
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
// Replace to fix tss-lib dependency issue with ed25519

View File

@ -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/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.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
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.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.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.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
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/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/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=
@ -74,9 +77,12 @@ github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlT
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
@ -144,6 +150,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-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.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
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/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@ -234,6 +241,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=

View File

@ -18,7 +18,9 @@ import (
"syscall"
"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/signing"
"github.com/bnb-chain/tss-lib/v2/tss"
)
@ -35,6 +37,11 @@ type Message struct {
TotalRounds int `json:"totalRounds,omitempty"`
FromPartyIndex int `json:"fromPartyIndex,omitempty"`
Error string `json:"error,omitempty"`
// Signing result fields
Signature string `json:"signature,omitempty"` // base64 encoded (R || S, 64 bytes)
R string `json:"r,omitempty"` // hex encoded
S string `json:"s,omitempty"` // hex encoded
RecoveryID int `json:"recoveryId,omitempty"` // for ecrecover
}
// Participant info
@ -118,9 +125,83 @@ func runKeygen() {
}
func runSign() {
// TODO: Implement signing
sendError("Signing not implemented yet")
os.Exit(1)
// Parse sign flags
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, "Original Threshold N from keygen")
participantsJSON := fs.String("participants", "[]", "Participants JSON array (current signers)")
messageHashB64 := fs.String("message-hash", "", "Message hash to sign (base64 encoded)")
shareDataB64 := fs.String("share-data", "", "Decrypted share data from keygen (base64 encoded)")
if err := fs.Parse(os.Args[2:]); err != nil {
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
os.Exit(1)
}
// Validate required fields
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
sendError("Missing required parameters")
os.Exit(1)
}
if *messageHashB64 == "" {
sendError("Missing message hash")
os.Exit(1)
}
if *shareDataB64 == "" {
sendError("Missing share data")
os.Exit(1)
}
// Parse participants (current signers, may be subset of original keygen participants)
var participants []Participant
if err := json.Unmarshal([]byte(*participantsJSON), &participants); err != nil {
sendError(fmt.Sprintf("Failed to parse participants: %v", err))
os.Exit(1)
}
if len(participants) < *thresholdT {
sendError(fmt.Sprintf("Not enough signers: got %d, need at least %d", len(participants), *thresholdT))
os.Exit(1)
}
// Decode message hash
messageHash, err := base64.StdEncoding.DecodeString(*messageHashB64)
if err != nil {
sendError(fmt.Sprintf("Failed to decode message hash: %v", err))
os.Exit(1)
}
// Decode share data
shareData, err := base64.StdEncoding.DecodeString(*shareDataB64)
if err != nil {
sendError(fmt.Sprintf("Failed to decode share data: %v", err))
os.Exit(1)
}
// Run sign protocol
result, err := executeSign(
*sessionID,
*partyID,
*partyIndex,
*thresholdT,
*thresholdN,
participants,
messageHash,
shareData,
)
if err != nil {
sendError(fmt.Sprintf("Signing failed: %v", err))
os.Exit(1)
}
// Send result
sendSignResult(result.Signature, result.R, result.S, result.RecoveryID)
}
type keygenResult struct {
@ -405,3 +486,237 @@ func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
data, _ := json.Marshal(msg)
fmt.Println(string(data))
}
// =============================================================================
// Signing Implementation
// =============================================================================
type signResult struct {
Signature []byte
R *big.Int
S *big.Int
RecoveryID int
}
func executeSign(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participants []Participant,
messageHash []byte,
shareData []byte,
) (*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()
}()
// Deserialize keygen save data
var saveData keygen.LocalPartySaveData
if err := json.Unmarshal(shareData, &saveData); err != nil {
return nil, fmt.Errorf("failed to deserialize share data: %w", err)
}
// Create TSS party IDs for current signers (may be subset of original keygen participants)
tssPartyIDs := make([]*tss.PartyID, len(participants))
var selfTSSID *tss.PartyID
for i, p := range participants {
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs[i] = partyKey
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
return nil, fmt.Errorf("self party not found in participants")
}
// Sort party IDs
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Build mapping from keygen index to sorted array index
// This is needed because TSS messages use keygen index, but tssPartyIDs is sorted
keygenIndexToSortedIndex := make(map[int]int)
for sortedIdx, pid := range sortedPartyIDs {
for _, p := range participants {
if p.PartyID == pid.Id {
keygenIndexToSortedIndex[p.PartyIndex] = sortedIdx
break
}
}
}
// Create peer context and parameters
// IMPORTANT: Use original thresholdN from keygen, not len(participants)
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, thresholdN, thresholdT)
// Convert message hash to big.Int
msgHashBigInt := new(big.Int).SetBytes(messageHash)
// Create channels
signerCount := len(participants)
outCh := make(chan tss.Message, signerCount*10)
endCh := make(chan *common.SignatureData, 1)
errCh := make(chan error, 1)
// Create local signing party
localParty := signing.NewLocalParty(msgHashBigInt, params, saveData, outCh, endCh)
// Build party index map for incoming messages
partyIndexMap := make(map[int]*tss.PartyID)
for sortedIdx, pid := range sortedPartyIDs {
for _, p := range participants {
if p.PartyID == pid.Id {
partyIndexMap[p.PartyIndex] = sortedPartyIDs[sortedIdx]
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)
// Increase buffer for large messages
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, len(buf))
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" {
handleSignIncomingMessage(msg, localParty, partyIndexMap, keygenIndexToSortedIndex, sortedPartyIDs, errCh)
}
}
}()
// Track progress
totalRounds := 9 // GG20 signing has 9 rounds
// Wait for completion
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case signData := <-endCh:
// Signing completed successfully
sendProgress(totalRounds, totalRounds)
// Build signature (R || S)
signature := make([]byte, 64)
rBytes := signData.R
sBytes := signData.S
copy(signature[32-len(rBytes):32], rBytes)
copy(signature[64-len(sBytes):64], sBytes)
r := new(big.Int).SetBytes(signData.R)
s := new(big.Int).SetBytes(signData.S)
recoveryID := int(signData.SignatureRecovery[0])
return &signResult{
Signature: signature,
R: r,
S: s,
RecoveryID: recoveryID,
}, nil
}
}
func handleSignIncomingMessage(
msg Message,
localParty tss.Party,
partyIndexMap map[int]*tss.PartyID,
keygenIndexToSortedIndex map[int]int,
sortedPartyIDs []*tss.PartyID,
errCh chan error,
) {
// Map keygen index to sorted array index
sortedIndex, exists := keygenIndexToSortedIndex[msg.FromPartyIndex]
if !exists {
return
}
if sortedIndex < 0 || sortedIndex >= len(sortedPartyIDs) {
return
}
payload, err := base64.StdEncoding.DecodeString(msg.Payload)
if err != nil {
return
}
parsedMsg, err := tss.ParseWireMessage(payload, sortedPartyIDs[sortedIndex], msg.IsBroadcast)
if err != nil {
return
}
go func() {
_, err := localParty.Update(parsedMsg)
if err != nil {
// Only send fatal errors
if !isDuplicateError(err) {
errCh <- err
}
}
}()
}
func sendSignResult(signature []byte, r, s *big.Int, recoveryID int) {
msg := Message{
Type: "sign_result",
Signature: base64.StdEncoding.EncodeToString(signature),
R: fmt.Sprintf("%064x", r),
S: fmt.Sprintf("%064x", s),
RecoveryID: recoveryID,
}
data, _ := json.Marshal(msg)
fmt.Println(string(data))
}

View File

@ -0,0 +1,463 @@
// Package main provides E2E tests for tss-party.exe
//
// This test simulates the full flow of keygen and signing using tss-party.exe
// by spawning multiple processes and coordinating message passing between them.
package main
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/stretchr/testify/require"
)
// TestTSSPartyE2E tests the full keygen and signing flow using tss-party.exe
func TestTSSPartyE2E(t *testing.T) {
// Find the tss-party.exe
exePath, err := filepath.Abs("tss-party.exe")
if err != nil {
t.Fatalf("Failed to get exe path: %v", err)
}
// Check if exe exists
if _, err := os.Stat(exePath); os.IsNotExist(err) {
t.Skipf("tss-party.exe not found at %s, run 'go build' first", exePath)
}
// Test parameters
sessionID := "test-session-001"
// In tss-lib: threshold=t means t+1 signers required
// For 2-of-3: we want 2 signers, so t=1 (1+1=2)
thresholdT := 1 // t=1 means 2 signers required (t+1=2)
thresholdN := 3
password := "test-password-123"
participants := []Participant{
{PartyID: "party-0", PartyIndex: 0},
{PartyID: "party-1", PartyIndex: 1},
{PartyID: "party-2", PartyIndex: 2},
}
// ============================================
// Step 1: Run Keygen
// ============================================
t.Log("========================================")
t.Log(" Step 1: Running Keygen")
t.Log("========================================")
keygenResults := runKeygenE2E(t, exePath, sessionID, thresholdT, thresholdN, participants, password)
require.Len(t, keygenResults, 3, "Should have 3 keygen results")
// Verify all parties have the same public key
pubKey0 := keygenResults[0].PublicKey
for i, r := range keygenResults {
require.Equal(t, pubKey0, r.PublicKey, "Party %d should have same public key", i)
}
t.Logf("Keygen completed! Public key: %s", hex.EncodeToString(pubKey0))
// ============================================
// Step 2: Run Signing with 2 parties
// ============================================
t.Log("========================================")
t.Log(" Step 2: Running Signing (2-of-3)")
t.Log("========================================")
message := []byte("Hello MPC World!")
messageHash := sha256.Sum256(message)
// Sign with parties 0 and 1
signers := []Participant{
participants[0],
participants[1],
}
signerShares := [][]byte{
keygenResults[0].EncryptedShare,
keygenResults[1].EncryptedShare,
}
signResult := runSignE2E(t, exePath, sessionID+"-sign", thresholdT, thresholdN, signers, signerShares, messageHash[:], password)
t.Logf("Signing completed!")
t.Logf(" R: %s", signResult.R)
t.Logf(" S: %s", signResult.S)
t.Logf(" RecoveryID: %d", signResult.RecoveryID)
// ============================================
// Step 3: Verify Signature
// ============================================
t.Log("========================================")
t.Log(" Step 3: Verifying Signature")
t.Log("========================================")
// Parse public key
pubKeyECDSA := parseCompressedPublicKey(t, pubKey0)
// Parse R and S
rBigInt, ok := new(big.Int).SetString(signResult.R, 16)
require.True(t, ok, "Failed to parse R")
sBigInt, ok := new(big.Int).SetString(signResult.S, 16)
require.True(t, ok, "Failed to parse S")
// Verify
valid := ecdsa.Verify(pubKeyECDSA, messageHash[:], rBigInt, sBigInt)
require.True(t, valid, "Signature verification should pass")
t.Log("✓ Signature verified successfully!")
t.Log("========================================")
t.Log(" E2E Test PASSED!")
t.Log("========================================")
}
type keygenE2EResult struct {
PublicKey []byte
EncryptedShare []byte
}
type signE2EResult struct {
Signature []byte
R string
S string
RecoveryID int
}
func runKeygenE2E(t *testing.T, exePath, sessionID string, thresholdT, thresholdN int, participants []Participant, password string) []*keygenE2EResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
participantsJSON, _ := json.Marshal(participants)
// Create processes for all parties
type partyProc struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
party Participant
}
procs := make([]*partyProc, len(participants))
for i, p := range participants {
args := []string{
"keygen",
"-session-id", sessionID,
"-party-id", p.PartyID,
"-party-index", fmt.Sprintf("%d", p.PartyIndex),
"-threshold-t", fmt.Sprintf("%d", thresholdT),
"-threshold-n", fmt.Sprintf("%d", thresholdN),
"-participants", string(participantsJSON),
"-password", password,
}
cmd := exec.CommandContext(ctx, exePath, args...)
stdin, err := cmd.StdinPipe()
require.NoError(t, err)
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
cmd.Stderr = os.Stderr
err = cmd.Start()
require.NoError(t, err)
procs[i] = &partyProc{
cmd: cmd,
stdin: stdin,
stdout: stdout,
party: p,
}
}
// Message router
type routedMsg struct {
fromIndex int
isBroadcast bool
toParties []string
payload string
}
msgChan := make(chan routedMsg, 100)
// Result channel
results := make([]*keygenE2EResult, len(participants))
var resultsMu sync.Mutex
// Read output from all processes
var wg sync.WaitGroup
for i, proc := range procs {
wg.Add(1)
go func(idx int, p *partyProc) {
defer wg.Done()
scanner := bufio.NewScanner(p.stdout)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, len(buf))
for scanner.Scan() {
var msg Message
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
continue
}
switch msg.Type {
case "outgoing":
msgChan <- routedMsg{
fromIndex: p.party.PartyIndex,
isBroadcast: msg.IsBroadcast,
toParties: msg.ToParties,
payload: msg.Payload,
}
case "result":
pubKey, _ := base64.StdEncoding.DecodeString(msg.PublicKey)
share, _ := base64.StdEncoding.DecodeString(msg.EncryptedShare)
resultsMu.Lock()
results[idx] = &keygenE2EResult{
PublicKey: pubKey,
EncryptedShare: share,
}
resultsMu.Unlock()
case "progress":
t.Logf("Party %d: Round %d/%d", idx, msg.Round, msg.TotalRounds)
case "error":
t.Errorf("Party %d error: %s", idx, msg.Error)
}
}
}(i, proc)
}
// Route messages between processes
go func() {
for {
select {
case <-ctx.Done():
return
case m := <-msgChan:
incomingMsg := Message{
Type: "incoming",
IsBroadcast: m.isBroadcast,
Payload: m.payload,
FromPartyIndex: m.fromIndex,
}
data, _ := json.Marshal(incomingMsg)
dataLine := string(data) + "\n"
if m.isBroadcast {
// Send to all other parties
for idx, proc := range procs {
if idx != m.fromIndex {
proc.stdin.Write([]byte(dataLine))
}
}
} else {
// Send to specific parties
for _, toPartyID := range m.toParties {
for _, proc := range procs {
if proc.party.PartyID == toPartyID {
proc.stdin.Write([]byte(dataLine))
break
}
}
}
}
}
}
}()
// Wait for all processes to complete
for _, proc := range procs {
proc.cmd.Wait()
}
return results
}
func runSignE2E(t *testing.T, exePath, sessionID string, thresholdT, thresholdN int, participants []Participant, shares [][]byte, messageHash []byte, password string) *signE2EResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
participantsJSON, _ := json.Marshal(participants)
messageHashB64 := base64.StdEncoding.EncodeToString(messageHash)
// Create processes for all signers
type partyProc struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
party Participant
}
procs := make([]*partyProc, len(participants))
for i, p := range participants {
// Decrypt share data (remove password hash prefix)
shareData := shares[i][32:] // Skip the 32-byte password hash
shareDataB64 := base64.StdEncoding.EncodeToString(shareData)
args := []string{
"sign",
"-session-id", sessionID,
"-party-id", p.PartyID,
"-party-index", fmt.Sprintf("%d", p.PartyIndex),
"-threshold-t", fmt.Sprintf("%d", thresholdT),
"-threshold-n", fmt.Sprintf("%d", thresholdN),
"-participants", string(participantsJSON),
"-message-hash", messageHashB64,
"-share-data", shareDataB64,
}
cmd := exec.CommandContext(ctx, exePath, args...)
stdin, err := cmd.StdinPipe()
require.NoError(t, err)
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
cmd.Stderr = os.Stderr
err = cmd.Start()
require.NoError(t, err)
procs[i] = &partyProc{
cmd: cmd,
stdin: stdin,
stdout: stdout,
party: p,
}
}
// Message router
type routedMsg struct {
fromIndex int
isBroadcast bool
toParties []string
payload string
}
msgChan := make(chan routedMsg, 100)
// Result channel
var result *signE2EResult
var resultMu sync.Mutex
resultChan := make(chan struct{})
// Read output from all processes
var wg sync.WaitGroup
for i, proc := range procs {
wg.Add(1)
go func(idx int, p *partyProc) {
defer wg.Done()
scanner := bufio.NewScanner(p.stdout)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, len(buf))
for scanner.Scan() {
var msg Message
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
continue
}
switch msg.Type {
case "outgoing":
msgChan <- routedMsg{
fromIndex: p.party.PartyIndex,
isBroadcast: msg.IsBroadcast,
toParties: msg.ToParties,
payload: msg.Payload,
}
case "sign_result":
sig, _ := base64.StdEncoding.DecodeString(msg.Signature)
resultMu.Lock()
if result == nil {
result = &signE2EResult{
Signature: sig,
R: msg.R,
S: msg.S,
RecoveryID: msg.RecoveryID,
}
close(resultChan)
}
resultMu.Unlock()
case "progress":
t.Logf("Party %d: Round %d/%d", idx, msg.Round, msg.TotalRounds)
case "error":
t.Errorf("Party %d error: %s", idx, msg.Error)
}
}
}(i, proc)
}
// Route messages between processes
go func() {
for {
select {
case <-ctx.Done():
return
case m := <-msgChan:
incomingMsg := Message{
Type: "incoming",
IsBroadcast: m.isBroadcast,
Payload: m.payload,
FromPartyIndex: m.fromIndex,
}
data, _ := json.Marshal(incomingMsg)
dataLine := string(data) + "\n"
if m.isBroadcast {
// Send to all other parties
for idx, proc := range procs {
if idx != m.fromIndex {
proc.stdin.Write([]byte(dataLine))
}
}
} else {
// Send to specific parties
for _, toPartyID := range m.toParties {
for _, proc := range procs {
if proc.party.PartyID == toPartyID {
proc.stdin.Write([]byte(dataLine))
break
}
}
}
}
}
}
}()
// Wait for result or timeout
select {
case <-resultChan:
case <-ctx.Done():
t.Fatal("Signing timed out")
}
// Wait for all processes to complete
for _, proc := range procs {
proc.cmd.Wait()
}
return result
}
func parseCompressedPublicKey(t *testing.T, compressed []byte) *ecdsa.PublicKey {
pubKey, err := btcec.ParsePubKey(compressed)
require.NoError(t, err)
return pubKey.ToECDSA()
}