@bitpay-labs/bitcore-wallet-service 11.10.9 → 11.10.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/package.json +2 -2
- package/src/lib/messagebroker.ts +9 -3
- package/src/lib/model/notification.ts +2 -2
- package/src/lib/routes/helpers/error.ts +1 -0
- package/src/lib/routes/tss.ts +51 -4
- package/src/lib/server.ts +10 -6
- package/src/lib/storage.ts +5 -3
- package/src/lib/tss.ts +251 -34
- package/test/integration/server.test.ts +2 -2
- package/test/messagebroker.test.ts +137 -0
- package/test/tss.test.ts +165 -0
- package/ts_build/package.json +4 -4
- package/ts_build/src/lib/messagebroker.d.ts +4 -2
- package/ts_build/src/lib/messagebroker.d.ts.map +1 -1
- package/ts_build/src/lib/messagebroker.js +4 -1
- package/ts_build/src/lib/messagebroker.js.map +1 -1
- package/ts_build/src/lib/model/notification.d.ts +2 -2
- package/ts_build/src/lib/model/notification.d.ts.map +1 -1
- package/ts_build/src/lib/routes/helpers/error.d.ts.map +1 -1
- package/ts_build/src/lib/routes/helpers/error.js +1 -0
- package/ts_build/src/lib/routes/helpers/error.js.map +1 -1
- package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
- package/ts_build/src/lib/routes/tss.js +31 -4
- package/ts_build/src/lib/routes/tss.js.map +1 -1
- package/ts_build/src/lib/server.d.ts +3 -2
- package/ts_build/src/lib/server.d.ts.map +1 -1
- package/ts_build/src/lib/server.js +9 -5
- package/ts_build/src/lib/server.js.map +1 -1
- package/ts_build/src/lib/storage.d.ts +5 -3
- package/ts_build/src/lib/storage.d.ts.map +1 -1
- package/ts_build/src/lib/storage.js.map +1 -1
- package/ts_build/src/lib/tss.d.ts +20 -4
- package/ts_build/src/lib/tss.d.ts.map +1 -1
- package/ts_build/src/lib/tss.js +155 -17
- package/ts_build/src/lib/tss.js.map +1 -1
- package/ts_build/test/integration/server.test.js +2 -2
- package/ts_build/test/integration/server.test.js.map +1 -1
- package/ts_build/test/messagebroker.test.d.ts +2 -0
- package/ts_build/test/messagebroker.test.d.ts.map +1 -0
- package/ts_build/test/messagebroker.test.js +155 -0
- package/ts_build/test/messagebroker.test.js.map +1 -0
- package/ts_build/test/tss.test.d.ts +2 -0
- package/ts_build/test/tss.test.d.ts.map +1 -0
- package/ts_build/test/tss.test.js +190 -0
- package/ts_build/test/tss.test.js.map +1 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ More about BWS at https://blog.bitpay.com/announcing-the-bitcore-wallet-suite/
|
|
|
23
23
|
```sh
|
|
24
24
|
git clone https://github.com/bitpay/bitcore.git
|
|
25
25
|
cd bitcore
|
|
26
|
-
npm
|
|
26
|
+
npm ci
|
|
27
27
|
npm run bws
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -86,9 +86,6 @@ There are plenty examples of creating and sending proposals in the `/test/integr
|
|
|
86
86
|
- copay running on port: 8100
|
|
87
87
|
- bitcoin-core running on regtest mode (blue icon logo)
|
|
88
88
|
|
|
89
|
-
> mongo topology crashes sometimes due to notifications being incompatible in a web browser
|
|
90
|
-
> **bitcore-wallet-service/lib/notificationbroadcaster.js**
|
|
91
|
-
> Note: If testing on a PC browser, comment out notificationbroadcaster.js to disable notifications.
|
|
92
89
|
|
|
93
90
|
### Steps:
|
|
94
91
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@bitpay-labs/bitcore-wallet-service",
|
|
3
3
|
"description": "A service for Mutisig HD Bitcoin Wallets",
|
|
4
4
|
"author": "BitPay Inc",
|
|
5
|
-
"version": "11.10.
|
|
5
|
+
"version": "11.10.10",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "ts_build/src/index.js",
|
|
8
8
|
"types": "./ts_build/src/index.d.ts",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"email": "kjoseph@bitpay.com"
|
|
108
108
|
}
|
|
109
109
|
],
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "ea86af5f3e1049d0ee1469fd0cd1fa34c2a579c4"
|
|
111
111
|
}
|
package/src/lib/messagebroker.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { EventEmitter } from 'events';
|
|
|
2
2
|
import * as io from 'socket.io-client';
|
|
3
3
|
import 'source-map-support/register';
|
|
4
4
|
import logger from './logger';
|
|
5
|
+
import type { Notification } from './model/notification';
|
|
5
6
|
|
|
6
7
|
export class MessageBroker extends EventEmitter {
|
|
7
|
-
remote: boolean;
|
|
8
|
+
remote: boolean = false;
|
|
8
9
|
mq: io.Socket;
|
|
10
|
+
|
|
9
11
|
constructor(opts) {
|
|
10
12
|
super();
|
|
11
13
|
|
|
@@ -28,7 +30,7 @@ export class MessageBroker extends EventEmitter {
|
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
send(data) {
|
|
33
|
+
send(data: Notification) {
|
|
32
34
|
if (this.remote) {
|
|
33
35
|
this.mq.emit('msg', data);
|
|
34
36
|
} else {
|
|
@@ -36,7 +38,11 @@ export class MessageBroker extends EventEmitter {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
onMessage(handler) {
|
|
41
|
+
onMessage(handler: (data: Notification) => void) {
|
|
40
42
|
this.on('msg', handler);
|
|
41
43
|
}
|
|
44
|
+
|
|
45
|
+
offMessage(handler: (data: Notification) => void) {
|
|
46
|
+
this.off('msg', handler);
|
|
47
|
+
}
|
|
42
48
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
export interface INotification {
|
|
25
25
|
version: string;
|
|
26
26
|
createdOn: number;
|
|
27
|
-
id: number;
|
|
27
|
+
id: string | number;
|
|
28
28
|
type: string;
|
|
29
29
|
data: any;
|
|
30
30
|
walletId: string;
|
|
@@ -32,7 +32,7 @@ export interface INotification {
|
|
|
32
32
|
isCreator: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export class Notification {
|
|
35
|
+
export class Notification implements INotification {
|
|
36
36
|
version: string;
|
|
37
37
|
createdOn: number;
|
|
38
38
|
id: string | number;
|
|
@@ -13,6 +13,7 @@ export class ApiErrorHelper {
|
|
|
13
13
|
returnError(err: any, res: express.Response, req: express.Request): void {
|
|
14
14
|
// make sure headers have not been sent as this leads to an uncaught error
|
|
15
15
|
if (res.headersSent) {
|
|
16
|
+
res.end();
|
|
16
17
|
return;
|
|
17
18
|
}
|
|
18
19
|
if (err instanceof ClientError) {
|
package/src/lib/routes/tss.ts
CHANGED
|
@@ -37,17 +37,40 @@ export class TssRouter {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
router.get('/v1/tss/keygen/:id/:round', authTssRequest(), async function(req, res) {
|
|
40
|
+
let interval: NodeJS.Timeout;
|
|
40
41
|
try {
|
|
42
|
+
const abort = new AbortController();
|
|
43
|
+
res.on('close', () => { clearInterval(interval); abort.abort(); });
|
|
44
|
+
|
|
41
45
|
const { id, round } = req.params as { [key: string]: string };
|
|
46
|
+
const { maxWaitTime } = req.query as { [key: string]: string };
|
|
42
47
|
const copayerId = req.headers['x-identity'];
|
|
43
48
|
if (round === 'secret') {
|
|
44
49
|
const secret = await TssKeyGen.getBwsJoinSecret({ id, copayerId });
|
|
45
50
|
return res.json({ secret });
|
|
46
51
|
}
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
|
|
53
|
+
// Validate access and fetch session before committing to a streaming response, so that errors like
|
|
54
|
+
// "session not found" or "not a participant" can still be returned with a proper
|
|
55
|
+
// HTTP status instead of silently becoming an empty 200 (see below).
|
|
56
|
+
const session = await TssKeyGen.getSessionForCopayer({ id, copayerId });
|
|
57
|
+
|
|
58
|
+
if (abort.signal.aborted || res.closed) {
|
|
59
|
+
return; // Connection closed by client, stop processing
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Keep the connection alive while waiting for the change stream to return a result.
|
|
63
|
+
// Headers must be finalized before writing the first heartbeat byte.
|
|
64
|
+
// Flush ensures the heartbeat is sent immediately to keep the connection alive.
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
+
interval = setInterval(() => { res.write('\n'); res.flush(); }, 1000);
|
|
67
|
+
|
|
68
|
+
const { messages, publicKey } = await TssKeyGen.getMessagesForParty({ session, round: parseInt(round), copayerId, maxWaitTimeSec: parseInt(maxWaitTime), abortSignal: abort.signal });
|
|
69
|
+
return res.end(JSON.stringify({ messages, publicKey }));
|
|
49
70
|
} catch (err) {
|
|
50
71
|
return returnError(err ?? 'unknown', res, req);
|
|
72
|
+
} finally {
|
|
73
|
+
clearInterval(interval);
|
|
51
74
|
}
|
|
52
75
|
});
|
|
53
76
|
|
|
@@ -104,13 +127,37 @@ export class TssRouter {
|
|
|
104
127
|
});
|
|
105
128
|
|
|
106
129
|
router.get('/v1/tss/sign/:id/:round', authTssRequest(), async function(req, res) {
|
|
130
|
+
let interval: NodeJS.Timeout;
|
|
107
131
|
try {
|
|
132
|
+
const abort = new AbortController();
|
|
133
|
+
res.on('close', () => { clearInterval(interval); abort.abort(); });
|
|
134
|
+
|
|
108
135
|
const { id, round } = req.params as { [key: string]: string };
|
|
136
|
+
const { maxWaitTime } = req.query as { [key: string]: string };
|
|
109
137
|
const copayerId = req.headers['x-identity'];
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
|
|
139
|
+
// Validate access and fetch session before committing to a streaming response, so that errors like
|
|
140
|
+
// "session not found" or "not a participant" can still be returned with a proper
|
|
141
|
+
// HTTP status instead of silently becoming an empty 200 (see below).
|
|
142
|
+
const session = await TssSign.getSessionForCopayer({ id, copayerId });
|
|
143
|
+
|
|
144
|
+
if (abort.signal.aborted || res.closed) {
|
|
145
|
+
return; // Connection closed by client, stop processing
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Keep the connection alive while waiting for the change stream to return a result.
|
|
149
|
+
// Headers must be finalized before writing the first heartbeat byte.
|
|
150
|
+
// Flush ensures the heartbeat is sent immediately to keep the connection alive.
|
|
151
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
152
|
+
interval = setInterval(() => { res.write('\n'); res.flush(); }, 1000);
|
|
153
|
+
|
|
154
|
+
const { messages, signature, participants } = await TssSign.getMessagesForParty({ session, round: parseInt(round), copayerId, maxWaitTimeSec: parseInt(maxWaitTime), abortSignal: abort.signal });
|
|
155
|
+
clearInterval(interval);
|
|
156
|
+
return res.end(JSON.stringify({ messages, signature, participants }));
|
|
112
157
|
} catch (err) {
|
|
113
158
|
return returnError(err ?? 'unknown', res, req);
|
|
159
|
+
} finally {
|
|
160
|
+
clearInterval(interval);
|
|
114
161
|
}
|
|
115
162
|
});
|
|
116
163
|
|
package/src/lib/server.ts
CHANGED
|
@@ -326,12 +326,9 @@ export class WalletService implements IWalletService {
|
|
|
326
326
|
);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
static handleIncomingNotifications(
|
|
330
|
-
cb = cb || function() { };
|
|
331
|
-
|
|
329
|
+
static handleIncomingNotifications(_notification: INotification) {
|
|
332
330
|
// do nothing here....
|
|
333
331
|
// bc height cache is cleared on bcmonitor
|
|
334
|
-
return cb();
|
|
335
332
|
}
|
|
336
333
|
|
|
337
334
|
static shutDown(cb) {
|
|
@@ -474,6 +471,13 @@ export class WalletService implements IWalletService {
|
|
|
474
471
|
return storage;
|
|
475
472
|
}
|
|
476
473
|
|
|
474
|
+
static getMessageBroker() {
|
|
475
|
+
if (!initialized) {
|
|
476
|
+
throw new Error('Message broker requested before server was initialized');
|
|
477
|
+
}
|
|
478
|
+
return messageBroker;
|
|
479
|
+
}
|
|
480
|
+
|
|
477
481
|
_runLocked(cb, task, waitTime?: number) {
|
|
478
482
|
$.checkState(this.walletId, 'Failed state: this.walletId undefined at <_runLocked()>');
|
|
479
483
|
this.lock.runLocked(this.walletId, { waitTime }, cb, task);
|
|
@@ -1152,7 +1156,7 @@ export class WalletService implements IWalletService {
|
|
|
1152
1156
|
try {
|
|
1153
1157
|
const isValid = this._verifyRequestPubKey(opts.requestPubKey, opts.signature, target.xPubKey);
|
|
1154
1158
|
if (!isValid) return cb(Errors.NOT_AUTHORIZED);
|
|
1155
|
-
} catch
|
|
1159
|
+
} catch {
|
|
1156
1160
|
return cb(Errors.NOT_AUTHORIZED);
|
|
1157
1161
|
}
|
|
1158
1162
|
|
|
@@ -3857,7 +3861,7 @@ export class WalletService implements IWalletService {
|
|
|
3857
3861
|
const notifications = res
|
|
3858
3862
|
.flat()
|
|
3859
3863
|
.map((n: INotification) => ({ ...n, walletId: this.walletId }))
|
|
3860
|
-
.sort((a, b) => a.id
|
|
3864
|
+
.sort((a, b) => a.id?.toString()?.localeCompare(b.id?.toString()));
|
|
3861
3865
|
|
|
3862
3866
|
return cb(null, notifications);
|
|
3863
3867
|
}
|
package/src/lib/storage.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as async from 'async';
|
|
2
2
|
import _ from 'lodash';
|
|
3
|
-
import { Db } from 'mongodb';
|
|
4
3
|
import * as mongodb from 'mongodb';
|
|
5
4
|
import preconditions from 'preconditions';
|
|
6
5
|
import { BCHAddressTranslator } from './bchaddresstranslator'; // only for migration
|
|
@@ -19,8 +18,11 @@ import {
|
|
|
19
18
|
TxProposal,
|
|
20
19
|
Wallet
|
|
21
20
|
} from './model';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { TssKeyGenModel } from './model/tsskeygen';
|
|
22
|
+
import { TssSigGenModel } from './model/tsssign';
|
|
23
|
+
import type { ITssKeyMessageObject } from './model/tsskeygen';
|
|
24
|
+
import type { ITssSigMessageObject } from './model/tsssign';
|
|
25
|
+
import type { Db } from 'mongodb';
|
|
24
26
|
|
|
25
27
|
const $ = preconditions.singleton();
|
|
26
28
|
|
package/src/lib/tss.ts
CHANGED
|
@@ -1,36 +1,184 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
1
2
|
import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core';
|
|
2
3
|
import { Constants } from './common/constants';
|
|
3
4
|
import { Errors } from './errors/errordefinitions';
|
|
4
5
|
import logger from './logger';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { TssKeyGenModel } from './model/tsskeygen';
|
|
7
|
+
import { TssSigGenModel } from './model/tsssign';
|
|
7
8
|
import { WalletService, checkRequired } from './server';
|
|
8
9
|
import { Storage } from './storage';
|
|
10
|
+
import type { INotification } from './model/notification';
|
|
11
|
+
import type { ITssKeyMessageObject } from './model/tsskeygen';
|
|
12
|
+
import type { ITssSigMessageObject } from './model/tsssign';
|
|
13
|
+
|
|
14
|
+
type SessionHandler = (message: INotification) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
const sessionRegistry = new Map<string, Set<SessionHandler>>();
|
|
17
|
+
let dispatcherRegistered = false;
|
|
18
|
+
|
|
19
|
+
function sessionKey(type: string, id: string | number): string {
|
|
20
|
+
return JSON.stringify([type, id]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function dispatchSessionMessage(message: INotification): void {
|
|
24
|
+
const handlers = sessionRegistry.get(sessionKey(message.type, message.id));
|
|
25
|
+
for (const notify of [...(handlers ?? [])]) {
|
|
26
|
+
void notify(message).catch(err => {
|
|
27
|
+
logger.error('Error handling TSS session update: %o', err);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function subscribeToSession(type: string, id: string, handler: SessionHandler): () => void {
|
|
33
|
+
if (!dispatcherRegistered) {
|
|
34
|
+
WalletService.getMessageBroker().onMessage(dispatchSessionMessage);
|
|
35
|
+
dispatcherRegistered = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = sessionKey(type, id);
|
|
39
|
+
let handlers = sessionRegistry.get(key);
|
|
40
|
+
if (!handlers) {
|
|
41
|
+
handlers = new Set();
|
|
42
|
+
sessionRegistry.set(key, handlers);
|
|
43
|
+
}
|
|
44
|
+
handlers.add(handler);
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
if (!handlers.delete(handler)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (handlers.size === 0) {
|
|
51
|
+
sessionRegistry.delete(key);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get a bounded wait time in milliseconds for TSS message retrieval. The wait time is bounded between 0 and 20 seconds.
|
|
58
|
+
* If no maxWaitTimeSec is provided, the default is maxSec seconds (default: 20).
|
|
59
|
+
* @param {number} maxWaitTimeSec The value to be bounded
|
|
60
|
+
* @param {number} maxSec The maximum wait time in seconds (default: 20)
|
|
61
|
+
* @param {number} minSec The minimum wait time in seconds (default: 0)
|
|
62
|
+
*/
|
|
63
|
+
function getBoundedWaitTime(maxWaitTimeSec?: number, maxSec = 20, minSec = 0): number {
|
|
64
|
+
maxWaitTimeSec = Math.max(isNaN(maxWaitTimeSec) ? maxSec : maxWaitTimeSec, minSec);
|
|
65
|
+
const maxWaitTime = Math.min(maxWaitTimeSec, maxSec) * 1000;
|
|
66
|
+
return maxWaitTime;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Common function for listening to the completion of a TSS session's round.
|
|
71
|
+
*/
|
|
72
|
+
async function listenForSessionComplete<T extends TssKeyGenModel | TssSigGenModel>(params: {
|
|
73
|
+
/** Message type to listen for */
|
|
74
|
+
messageType: typeof TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE | typeof TssSignClass.TSS_SIGGEN_MESSAGE_TYPE;
|
|
75
|
+
/** Session to listen for updates */
|
|
76
|
+
session: T;
|
|
77
|
+
/** Function to determine if the round is complete */
|
|
78
|
+
isComplete: (session: T) => boolean;
|
|
79
|
+
/** Function to fetch the latest session state */
|
|
80
|
+
fetchSession: (params: { id: string }) => Promise<T>;
|
|
81
|
+
/** Maximum time (in milliseconds) to wait for the round to complete */
|
|
82
|
+
maxWaitTime: number;
|
|
83
|
+
/** Abort signal to cancel waiting for a complete round */
|
|
84
|
+
abortSignal?: AbortSignal;
|
|
85
|
+
}): Promise<T> {
|
|
86
|
+
const { messageType, isComplete, fetchSession, maxWaitTime, abortSignal } = params;
|
|
87
|
+
let { session } = params;
|
|
88
|
+
|
|
89
|
+
const events = new EventEmitter();
|
|
90
|
+
const sessionUpdateHandler = async () => {
|
|
91
|
+
try {
|
|
92
|
+
const _session = await fetchSession({ id: session.id });
|
|
93
|
+
if (isComplete(_session)) {
|
|
94
|
+
unsubscribe();
|
|
95
|
+
events.emit('session', _session);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Do not throw on possibly transient db connection errors. At worst, this runs until the maxWaitTime expires
|
|
99
|
+
logger.error('Error fetching updated TSS session: %o - %o', session.id, err);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const unsubscribe = subscribeToSession(messageType, session.id, sessionUpdateHandler);
|
|
103
|
+
|
|
104
|
+
// Listen for session update events over the message broker service
|
|
105
|
+
let timer: NodeJS.Timeout;
|
|
106
|
+
const sessionUpdate = Promise.race([
|
|
107
|
+
new Promise<T>(r => events.once('session', r)),
|
|
108
|
+
new Promise<T>(r => timer = setTimeout(() => { unsubscribe(); r(session); }, maxWaitTime)),
|
|
109
|
+
new Promise<T>((_, j) => abortSignal?.addEventListener('abort', () => { unsubscribe(); j(new Error('Aborted')); }))
|
|
110
|
+
]);
|
|
111
|
+
sessionUpdate.catch(() => {}); // marks sessionUpdate as "handled" immediately; the await below still sees the rejection
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Check for an updated session one last time before awaiting the subscription.
|
|
115
|
+
// This is to prevent a race condition where the update arrives before we start listening for it.
|
|
116
|
+
const _session = await fetchSession({ id: session.id });
|
|
117
|
+
if (isComplete(_session)) {
|
|
118
|
+
session = _session;
|
|
119
|
+
} else if (!abortSignal?.aborted) {
|
|
120
|
+
session = await sessionUpdate;
|
|
121
|
+
}
|
|
122
|
+
return session;
|
|
123
|
+
} finally {
|
|
124
|
+
unsubscribe();
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
events.removeAllListeners();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
9
129
|
|
|
10
130
|
class TssKeyGenClass {
|
|
131
|
+
static TSS_KEYGEN_MESSAGE_TYPE = 'TssKeyGenMessage' as const;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if a copayer is a participant in a TSS keygen session and return the session.
|
|
135
|
+
* Throws an error if the copayer is not a participant or if the session does not exist.
|
|
136
|
+
*/
|
|
137
|
+
async getSessionForCopayer(params: {
|
|
138
|
+
/** Session ID */
|
|
139
|
+
id: string;
|
|
140
|
+
/** Copayer ID of the requesting party */
|
|
141
|
+
copayerId: string;
|
|
142
|
+
}): Promise<TssKeyGenModel> {
|
|
143
|
+
const { id, copayerId } = params;
|
|
144
|
+
const storage = WalletService.getStorage();
|
|
145
|
+
const session = await storage.fetchTssKeyGenSession({ id });
|
|
146
|
+
if (!session) {
|
|
147
|
+
throw Errors.TSS_SESSION_NOT_FOUND;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isParticipant = session.participants.includes(copayerId);
|
|
151
|
+
if (!isParticipant) {
|
|
152
|
+
throw Errors.TSS_NON_PARTICIPANT;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return session;
|
|
156
|
+
}
|
|
157
|
+
|
|
11
158
|
/**
|
|
12
159
|
* Get messages for a given party in a TSS keygen session.
|
|
13
160
|
* Only returns messages if all other parties have sent their messages for the round.
|
|
14
161
|
*/
|
|
15
162
|
async getMessagesForParty(params: {
|
|
16
|
-
/** Session
|
|
17
|
-
|
|
163
|
+
/** Session */
|
|
164
|
+
session: TssKeyGenModel;
|
|
18
165
|
/** Round number */
|
|
19
166
|
round: number;
|
|
20
167
|
/** Copayer ID of the requesting party */
|
|
21
168
|
copayerId: string;
|
|
169
|
+
/** Maximum time (in seconds) to wait for a complete round */
|
|
170
|
+
maxWaitTimeSec?: number;
|
|
171
|
+
/** Abort signal to cancel waiting for a complete round */
|
|
172
|
+
abortSignal?: AbortSignal;
|
|
22
173
|
}): Promise<{
|
|
23
174
|
messages?: ITssKeyMessageObject[];
|
|
24
175
|
publicKey?: string;
|
|
25
176
|
hasKeyBackup?: boolean;
|
|
26
177
|
}> {
|
|
27
|
-
const {
|
|
178
|
+
const { round, copayerId, abortSignal } = params;
|
|
179
|
+
let { session } = params;
|
|
180
|
+
const maxWaitTime = getBoundedWaitTime(params.maxWaitTimeSec);
|
|
28
181
|
|
|
29
|
-
const storage = WalletService.getStorage();
|
|
30
|
-
const session = await storage.fetchTssKeyGenSession({ id });
|
|
31
|
-
if (!session) {
|
|
32
|
-
throw Errors.TSS_SESSION_NOT_FOUND;
|
|
33
|
-
}
|
|
34
182
|
if (!session.rounds[round]) {
|
|
35
183
|
return {};
|
|
36
184
|
}
|
|
@@ -40,15 +188,33 @@ class TssKeyGenClass {
|
|
|
40
188
|
throw Errors.TSS_NON_PARTICIPANT;
|
|
41
189
|
}
|
|
42
190
|
|
|
43
|
-
const
|
|
191
|
+
const isRoundComplete = (session) => {
|
|
192
|
+
const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
|
|
193
|
+
return otherPartyMsgs.length === session.n - 1;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (!isRoundComplete(session)) {
|
|
197
|
+
const storage = WalletService.getStorage();
|
|
198
|
+
session = await listenForSessionComplete<TssKeyGenModel>({
|
|
199
|
+
messageType: TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE,
|
|
200
|
+
session,
|
|
201
|
+
isComplete: isRoundComplete,
|
|
202
|
+
fetchSession: storage.fetchTssKeyGenSession.bind(storage),
|
|
203
|
+
maxWaitTime,
|
|
204
|
+
abortSignal
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
44
209
|
// Only return message if all other parties have sent their messages.
|
|
45
210
|
// This is to prevent complexity in TSS session management when processing rounds. There's
|
|
46
211
|
// no value in partially processing rounds with missing messages and messages can't be
|
|
47
212
|
// re-processed, so it makes sense to only return messages when the round is complete.
|
|
48
|
-
if (
|
|
213
|
+
if (!isRoundComplete(session)) {
|
|
49
214
|
return {};
|
|
50
215
|
}
|
|
51
216
|
|
|
217
|
+
const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
|
|
52
218
|
const messages = otherPartyMsgs.map(m => m.messages);
|
|
53
219
|
for (const m of messages) {
|
|
54
220
|
m.p2pMessages = m.p2pMessages.filter(m => m.to == partyId);
|
|
@@ -121,7 +287,7 @@ class TssKeyGenClass {
|
|
|
121
287
|
|
|
122
288
|
let result = false;
|
|
123
289
|
while (!result) {
|
|
124
|
-
result = await this._pushMessage({
|
|
290
|
+
result = await this._pushMessage({ session, message, storage });
|
|
125
291
|
if (!result) {
|
|
126
292
|
session = await storage.fetchTssKeyGenSession({ id });
|
|
127
293
|
}
|
|
@@ -225,8 +391,6 @@ class TssKeyGenClass {
|
|
|
225
391
|
* This will fail if the round is already complete or if the message is from a party that has already sent a message for the round.
|
|
226
392
|
*/
|
|
227
393
|
private async _pushMessage(params: {
|
|
228
|
-
/** Session ID */
|
|
229
|
-
id: string;
|
|
230
394
|
/** TSS keygen session fetched from BWS storage */
|
|
231
395
|
session: TssKeyGenModel;
|
|
232
396
|
/** Message to push to the session */
|
|
@@ -234,7 +398,8 @@ class TssKeyGenClass {
|
|
|
234
398
|
/** BWS storage instance */
|
|
235
399
|
storage: Storage;
|
|
236
400
|
}) {
|
|
237
|
-
const {
|
|
401
|
+
const { session, message, storage } = params;
|
|
402
|
+
const { id } = session;
|
|
238
403
|
const { round } = message;
|
|
239
404
|
|
|
240
405
|
const currentRound = session.getCurrentRound();
|
|
@@ -248,6 +413,8 @@ class TssKeyGenClass {
|
|
|
248
413
|
if (existing) {
|
|
249
414
|
throw Errors.TSS_ROUND_MESSAGE_EXISTS;
|
|
250
415
|
}
|
|
416
|
+
|
|
417
|
+
const messageBroker = WalletService.getMessageBroker();
|
|
251
418
|
|
|
252
419
|
try {
|
|
253
420
|
const result = await storage.storeTssKeyGenMessage({ id, message, __v: session.__v });
|
|
@@ -255,6 +422,7 @@ class TssKeyGenClass {
|
|
|
255
422
|
logger.error('Failed to store TSS key generation message %o %o %o', id, result, message);
|
|
256
423
|
throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS key generation message');
|
|
257
424
|
}
|
|
425
|
+
messageBroker.send({ type: TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE, id } as INotification);
|
|
258
426
|
return true;
|
|
259
427
|
} catch (e) {
|
|
260
428
|
if (e?.message?.startsWith('MONGO_DOC_OUTDATED')) {
|
|
@@ -373,25 +541,54 @@ class TssKeyGenClass {
|
|
|
373
541
|
export const TssKeyGen = new TssKeyGenClass();
|
|
374
542
|
|
|
375
543
|
class TssSignClass {
|
|
544
|
+
static TSS_SIGGEN_MESSAGE_TYPE = 'TssSigMessage' as const;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Check if a copayer is a participant in a TSS signature session and return the session.
|
|
548
|
+
* Throws an error if the copayer is not a participant or if the session does not exist.
|
|
549
|
+
*/
|
|
550
|
+
async getSessionForCopayer(params: {
|
|
551
|
+
/** Session ID */
|
|
552
|
+
id: string;
|
|
553
|
+
/** Copayer ID of the requesting party */
|
|
554
|
+
copayerId: string;
|
|
555
|
+
}): Promise<TssSigGenModel> {
|
|
556
|
+
const { id, copayerId } = params;
|
|
557
|
+
const storage = WalletService.getStorage();
|
|
558
|
+
const session = await storage.fetchTssSigSession({ id });
|
|
559
|
+
if (!session) {
|
|
560
|
+
throw Errors.TSS_SESSION_NOT_FOUND;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const party = session.participants.find(p => p.copayerId === copayerId);
|
|
564
|
+
if (!party) {
|
|
565
|
+
throw Errors.TSS_NON_PARTICIPANT;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return session;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
|
|
376
572
|
/**
|
|
377
573
|
* Get messages for a given party in a TSS signature session.
|
|
378
574
|
* Only returns messages if all other parties have sent their messages for the round.
|
|
379
575
|
*/
|
|
380
576
|
async getMessagesForParty(params: {
|
|
381
577
|
/** Session ID */
|
|
382
|
-
|
|
578
|
+
session: TssSigGenModel;
|
|
383
579
|
/** Round number */
|
|
384
580
|
round: number;
|
|
385
581
|
/** Copayer ID of the requesting party */
|
|
386
582
|
copayerId: string;
|
|
583
|
+
/** Maximum time (in seconds) to wait for a complete round */
|
|
584
|
+
maxWaitTimeSec?: number;
|
|
585
|
+
/** Abort signal to cancel waiting for a complete round */
|
|
586
|
+
abortSignal?: AbortSignal;
|
|
387
587
|
}): Promise<{ messages?: ITssSigMessageObject[]; signature?: ITssSigMessageObject['signature']; participants?: string[] }> {
|
|
388
|
-
const {
|
|
588
|
+
const { round, copayerId, abortSignal } = params;
|
|
589
|
+
let { session } = params;
|
|
590
|
+
const maxWaitTime = getBoundedWaitTime(params.maxWaitTimeSec);
|
|
389
591
|
|
|
390
|
-
const storage = WalletService.getStorage();
|
|
391
|
-
const session = await storage.fetchTssSigSession({ id });
|
|
392
|
-
if (!session) {
|
|
393
|
-
throw Errors.TSS_SESSION_NOT_FOUND;
|
|
394
|
-
}
|
|
395
592
|
if (!session.rounds[round]) {
|
|
396
593
|
return {};
|
|
397
594
|
}
|
|
@@ -401,20 +598,38 @@ class TssSignClass {
|
|
|
401
598
|
throw Errors.TSS_NON_PARTICIPANT;
|
|
402
599
|
}
|
|
403
600
|
|
|
601
|
+
const isRoundComplete = (session) => {
|
|
602
|
+
const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != party.partyId);
|
|
603
|
+
return otherPartyMsgs.length === session.m - 1;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
if (!isRoundComplete(session)) {
|
|
607
|
+
const storage = WalletService.getStorage();
|
|
608
|
+
session = await listenForSessionComplete<TssSigGenModel>({
|
|
609
|
+
messageType: TssSignClass.TSS_SIGGEN_MESSAGE_TYPE,
|
|
610
|
+
session,
|
|
611
|
+
isComplete: isRoundComplete,
|
|
612
|
+
fetchSession: storage.fetchTssSigSession.bind(storage),
|
|
613
|
+
maxWaitTime,
|
|
614
|
+
abortSignal
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
404
618
|
const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != party.partyId);
|
|
405
619
|
const participants = otherPartyMsgs.map(m => {
|
|
406
620
|
const p = session.participants.find(p => p.partyId === m.fromPartyId);
|
|
407
621
|
return p?.copayerId;
|
|
408
622
|
}).filter(Boolean) as string[];
|
|
409
623
|
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
for (const m of messages) {
|
|
413
|
-
m.p2pMessages = m.p2pMessages.filter(m => m.to == party.partyId);
|
|
414
|
-
}
|
|
415
|
-
return { messages, signature: session.signature, participants };
|
|
624
|
+
if (!isRoundComplete(session)) {
|
|
625
|
+
return { participants };
|
|
416
626
|
}
|
|
417
|
-
|
|
627
|
+
|
|
628
|
+
const messages = otherPartyMsgs.map(m => m.messages);
|
|
629
|
+
for (const m of messages) {
|
|
630
|
+
m.p2pMessages = m.p2pMessages.filter(m => m.to == party.partyId);
|
|
631
|
+
}
|
|
632
|
+
return { messages, signature: session.signature, participants };
|
|
418
633
|
}
|
|
419
634
|
|
|
420
635
|
/**
|
|
@@ -486,7 +701,7 @@ class TssSignClass {
|
|
|
486
701
|
|
|
487
702
|
let result = false;
|
|
488
703
|
while (!result) {
|
|
489
|
-
result = await this._pushMessage({
|
|
704
|
+
result = await this._pushMessage({ session, message, storage });
|
|
490
705
|
// `result` will be false if the session was stale (version conflict) and we need to retry
|
|
491
706
|
// Any other failure of the message state will result in a throw (e.g. same-message race condition, round already done, etc.)
|
|
492
707
|
if (!result) {
|
|
@@ -563,8 +778,6 @@ class TssSignClass {
|
|
|
563
778
|
* Push a TSS signature message to the session.
|
|
564
779
|
*/
|
|
565
780
|
private async _pushMessage(params: {
|
|
566
|
-
/** Session ID */
|
|
567
|
-
id: string;
|
|
568
781
|
/** TSS sig generation session fetched from BWS storage */
|
|
569
782
|
session: TssSigGenModel;
|
|
570
783
|
/** TSS signature message to be pushed */
|
|
@@ -572,7 +785,8 @@ class TssSignClass {
|
|
|
572
785
|
/** BWS storage instance */
|
|
573
786
|
storage: Storage;
|
|
574
787
|
}): Promise<boolean> {
|
|
575
|
-
const {
|
|
788
|
+
const { session, message, storage } = params;
|
|
789
|
+
const { id } = session;
|
|
576
790
|
const { round } = message;
|
|
577
791
|
|
|
578
792
|
const currentRound = session.getCurrentRound();
|
|
@@ -587,12 +801,15 @@ class TssSignClass {
|
|
|
587
801
|
throw Errors.TSS_ROUND_MESSAGE_EXISTS;
|
|
588
802
|
}
|
|
589
803
|
|
|
804
|
+
const messageBroker = WalletService.getMessageBroker();
|
|
805
|
+
|
|
590
806
|
try {
|
|
591
807
|
const result = await storage.storeTssSigMessage({ id, message, __v: session.__v });
|
|
592
808
|
if (!result.result.ok) {
|
|
593
809
|
logger.error('Failed to store TSS key generation message %o %o %o', id, result, message);
|
|
594
810
|
throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS key generation message');
|
|
595
811
|
}
|
|
812
|
+
messageBroker.send({ type: TssSignClass.TSS_SIGGEN_MESSAGE_TYPE, id } as INotification);
|
|
596
813
|
return true;
|
|
597
814
|
} catch (e) {
|
|
598
815
|
if (e?.message?.startsWith('MONGO_DOC_OUTDATED')) {
|