@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.
Files changed (46) hide show
  1. package/README.md +1 -4
  2. package/package.json +2 -2
  3. package/src/lib/messagebroker.ts +9 -3
  4. package/src/lib/model/notification.ts +2 -2
  5. package/src/lib/routes/helpers/error.ts +1 -0
  6. package/src/lib/routes/tss.ts +51 -4
  7. package/src/lib/server.ts +10 -6
  8. package/src/lib/storage.ts +5 -3
  9. package/src/lib/tss.ts +251 -34
  10. package/test/integration/server.test.ts +2 -2
  11. package/test/messagebroker.test.ts +137 -0
  12. package/test/tss.test.ts +165 -0
  13. package/ts_build/package.json +4 -4
  14. package/ts_build/src/lib/messagebroker.d.ts +4 -2
  15. package/ts_build/src/lib/messagebroker.d.ts.map +1 -1
  16. package/ts_build/src/lib/messagebroker.js +4 -1
  17. package/ts_build/src/lib/messagebroker.js.map +1 -1
  18. package/ts_build/src/lib/model/notification.d.ts +2 -2
  19. package/ts_build/src/lib/model/notification.d.ts.map +1 -1
  20. package/ts_build/src/lib/routes/helpers/error.d.ts.map +1 -1
  21. package/ts_build/src/lib/routes/helpers/error.js +1 -0
  22. package/ts_build/src/lib/routes/helpers/error.js.map +1 -1
  23. package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
  24. package/ts_build/src/lib/routes/tss.js +31 -4
  25. package/ts_build/src/lib/routes/tss.js.map +1 -1
  26. package/ts_build/src/lib/server.d.ts +3 -2
  27. package/ts_build/src/lib/server.d.ts.map +1 -1
  28. package/ts_build/src/lib/server.js +9 -5
  29. package/ts_build/src/lib/server.js.map +1 -1
  30. package/ts_build/src/lib/storage.d.ts +5 -3
  31. package/ts_build/src/lib/storage.d.ts.map +1 -1
  32. package/ts_build/src/lib/storage.js.map +1 -1
  33. package/ts_build/src/lib/tss.d.ts +20 -4
  34. package/ts_build/src/lib/tss.d.ts.map +1 -1
  35. package/ts_build/src/lib/tss.js +155 -17
  36. package/ts_build/src/lib/tss.js.map +1 -1
  37. package/ts_build/test/integration/server.test.js +2 -2
  38. package/ts_build/test/integration/server.test.js.map +1 -1
  39. package/ts_build/test/messagebroker.test.d.ts +2 -0
  40. package/ts_build/test/messagebroker.test.d.ts.map +1 -0
  41. package/ts_build/test/messagebroker.test.js +155 -0
  42. package/ts_build/test/messagebroker.test.js.map +1 -0
  43. package/ts_build/test/tss.test.d.ts +2 -0
  44. package/ts_build/test/tss.test.d.ts.map +1 -0
  45. package/ts_build/test/tss.test.js +190 -0
  46. 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 install
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.9",
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": "9fa63bb66aeab5024220c14c428345f8ae946f7c"
110
+ "gitHead": "ea86af5f3e1049d0ee1469fd0cd1fa34c2a579c4"
111
111
  }
@@ -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) {
@@ -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
- const { messages, publicKey } = await TssKeyGen.getMessagesForParty({ id, round: parseInt(round), copayerId });
48
- return res.json({ messages, publicKey });
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
- const { messages, signature, participants } = await TssSign.getMessagesForParty({ id, round: parseInt(round), copayerId });
111
- return res.json({ messages, signature, participants });
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(notification, cb) {
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 (e) {
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 - b.id);
3864
+ .sort((a, b) => a.id?.toString()?.localeCompare(b.id?.toString()));
3861
3865
 
3862
3866
  return cb(null, notifications);
3863
3867
  }
@@ -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 { ITssKeyMessageObject, TssKeyGenModel } from './model/tsskeygen';
23
- import { ITssSigMessageObject, TssSigGenModel } from './model/tsssign';
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 { ITssKeyMessageObject, TssKeyGenModel } from './model/tsskeygen';
6
- import { ITssSigMessageObject, TssSigGenModel } from './model/tsssign';
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 ID */
17
- id: string;
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 { id, round, copayerId } = params;
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 otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
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 (otherPartyMsgs.length !== session.n - 1) {
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({ id, session, message, storage });
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 { id, session, message, storage } = params;
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
- id: string;
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 { id, round, copayerId } = params;
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 (otherPartyMsgs.length === session.m - 1) {
411
- const messages = otherPartyMsgs.map(m => m.messages);
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
- return { participants };
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({ id, session, message, storage });
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 { id, session, message, storage } = params;
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')) {