@bitpay-labs/bitcore-wallet-service 11.11.2 → 11.11.3

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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/lib/common/constants.ts +1 -1
  3. package/src/lib/common/utils.ts +18 -1
  4. package/src/lib/model/tsskeygen.ts +16 -0
  5. package/src/lib/routes/maintenance.ts +11 -2
  6. package/src/lib/routes/middleware/authTssRequest.ts +28 -16
  7. package/src/lib/routes/middleware/verifyTssMessage.ts +4 -1
  8. package/src/lib/routes/services.ts +7 -1
  9. package/src/lib/routes/tss.ts +33 -30
  10. package/src/lib/server.ts +62 -17
  11. package/src/lib/storage.ts +39 -0
  12. package/src/lib/tss.ts +111 -7
  13. package/test/expressapp.test.ts +99 -0
  14. package/test/integration/server.test.ts +53 -1
  15. package/test/integration/tss.test.ts +428 -0
  16. package/ts_build/package.json +4 -4
  17. package/ts_build/src/lib/common/constants.d.ts +1 -1
  18. package/ts_build/src/lib/common/constants.js +1 -1
  19. package/ts_build/src/lib/common/index.d.ts +2 -1
  20. package/ts_build/src/lib/common/index.d.ts.map +1 -1
  21. package/ts_build/src/lib/common/utils.d.ts +1 -0
  22. package/ts_build/src/lib/common/utils.d.ts.map +1 -1
  23. package/ts_build/src/lib/common/utils.js +16 -1
  24. package/ts_build/src/lib/common/utils.js.map +1 -1
  25. package/ts_build/src/lib/model/tsskeygen.d.ts +10 -0
  26. package/ts_build/src/lib/model/tsskeygen.d.ts.map +1 -1
  27. package/ts_build/src/lib/model/tsskeygen.js +3 -0
  28. package/ts_build/src/lib/model/tsskeygen.js.map +1 -1
  29. package/ts_build/src/lib/routes/maintenance.d.ts.map +1 -1
  30. package/ts_build/src/lib/routes/maintenance.js +12 -4
  31. package/ts_build/src/lib/routes/maintenance.js.map +1 -1
  32. package/ts_build/src/lib/routes/middleware/authTssRequest.d.ts +6 -2
  33. package/ts_build/src/lib/routes/middleware/authTssRequest.d.ts.map +1 -1
  34. package/ts_build/src/lib/routes/middleware/authTssRequest.js +21 -14
  35. package/ts_build/src/lib/routes/middleware/authTssRequest.js.map +1 -1
  36. package/ts_build/src/lib/routes/middleware/verifyTssMessage.d.ts.map +1 -1
  37. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js +2 -1
  38. package/ts_build/src/lib/routes/middleware/verifyTssMessage.js.map +1 -1
  39. package/ts_build/src/lib/routes/services.d.ts.map +1 -1
  40. package/ts_build/src/lib/routes/services.js +6 -1
  41. package/ts_build/src/lib/routes/services.js.map +1 -1
  42. package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
  43. package/ts_build/src/lib/routes/tss.js +32 -30
  44. package/ts_build/src/lib/routes/tss.js.map +1 -1
  45. package/ts_build/src/lib/server.d.ts +23 -2
  46. package/ts_build/src/lib/server.d.ts.map +1 -1
  47. package/ts_build/src/lib/server.js +20 -3
  48. package/ts_build/src/lib/server.js.map +1 -1
  49. package/ts_build/src/lib/storage.d.ts +9 -0
  50. package/ts_build/src/lib/storage.d.ts.map +1 -1
  51. package/ts_build/src/lib/storage.js +26 -0
  52. package/ts_build/src/lib/storage.js.map +1 -1
  53. package/ts_build/src/lib/tss.d.ts +12 -0
  54. package/ts_build/src/lib/tss.d.ts.map +1 -1
  55. package/ts_build/src/lib/tss.js +64 -5
  56. package/ts_build/src/lib/tss.js.map +1 -1
  57. package/ts_build/test/expressapp.test.js +90 -0
  58. package/ts_build/test/expressapp.test.js.map +1 -1
  59. package/ts_build/test/integration/server.test.js +47 -0
  60. package/ts_build/test/integration/server.test.js.map +1 -1
  61. package/ts_build/test/integration/tss.test.d.ts.map +1 -1
  62. package/ts_build/test/integration/tss.test.js +344 -0
  63. package/ts_build/test/integration/tss.test.js.map +1 -1
package/src/lib/tss.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core';
3
3
  import { Constants } from './common/constants';
4
+ import { Utils } from './common/utils';
4
5
  import { Errors } from './errors/errordefinitions';
5
6
  import logger from './logger';
6
7
  import { TssKeyGenModel } from './model/tsskeygen';
@@ -553,7 +554,10 @@ class TssKeyGenClass {
553
554
  // }
554
555
  }
555
556
 
556
- /** Store the BWS join secret for the TSS keygen session */
557
+ /**
558
+ * Store the BWS join secret for the TSS keygen session
559
+ * @deprecated Use storeBwsJoinSecrets instead.
560
+ */
557
561
  async storeBwsJoinSecret(params: {
558
562
  /** Session ID */
559
563
  id: string;
@@ -587,10 +591,77 @@ class TssKeyGenClass {
587
591
  }
588
592
  }
589
593
 
590
- /** Get the BWS join secret for the TSS keygen session */
594
+ /**
595
+ * Store the party-encrypted BWS join secrets for a wallet on `chain`:`network` created with the TSS key.
596
+ * This can be called after the key generation session has expired to create additional wallets (e.g. other EVM chains) for the same key.
597
+ */
598
+ async storeBwsJoinSecrets(params: {
599
+ /** Session ID */
600
+ id: string;
601
+ /** Blockchain (e.g., 'btc', 'eth') */
602
+ chain: string;
603
+ /** Network (e.g., 'livenet', 'testnet') */
604
+ network: string;
605
+ /** Party encrypted BWS join secrets, keyed by partyId or by the party's keygen session copayer ID */
606
+ secrets: { [party: string]: string };
607
+ /** Copayer ID of the submitting party (must be the session creator) */
608
+ copayerId: string;
609
+ }) {
610
+ const { id, copayerId } = params;
611
+ const { chain, network } = getSecretChainNetwork(params.chain, params.network);
612
+ if (!params.secrets || typeof params.secrets !== 'object' || Array.isArray(params.secrets)) {
613
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid BWS join secrets provided');
614
+ }
615
+ const storage = WalletService.getStorage();
616
+ const session = await storage.fetchTssKeyGenSession({ id });
617
+
618
+ if (!session) {
619
+ throw Errors.TSS_SESSION_NOT_FOUND;
620
+ }
621
+
622
+ if (session.participants.indexOf(copayerId) !== 0) {
623
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Only the session creator can store the BWS join secret');
624
+ }
625
+
626
+ // Only store secrets keyed by partyId for the other parties (1..n-1)
627
+ const secrets: { [partyId: number]: string } = {};
628
+ for (const [party, secret] of Object.entries(params.secrets)) {
629
+ const partyId = /^\d+$/.test(party) ? parseInt(party) : session.participants.indexOf(party);
630
+ if (!(partyId > 0 && partyId < session.n) || typeof secret !== 'string' || !secret) {
631
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid BWS join secret for party ' + party);
632
+ }
633
+ if (secrets[partyId]) {
634
+ // Malformed secrets objects may be keyed by both partyId (number) and copayerId (string).
635
+ // While this technically won't break anything (only 1 secret will be stored), it indicates
636
+ // a malformation which could signal a broader issue with the client.
637
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Multiple BWS join secret for party ' + party);
638
+ }
639
+ secrets[partyId] = secret;
640
+ }
641
+
642
+ if (Object.keys(secrets).length !== session.n - 1) {
643
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Missing BWS join secrets for some parties');
644
+ }
645
+
646
+ const result = await storage.storeTssKeyBwsJoinSecrets({ id, chain, network, secrets });
647
+ if (!result.result.ok) {
648
+ logger.error('Failed to store TSS key generation BWS join secrets %o %o', id, result);
649
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS key generation BWS join secrets');
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Get the BWS join secret for the TSS keygen session.
655
+ * If chain & network are given, returns the requesting party's encrypted secret for that chain & network.
656
+ * Otherwise, returns the legacy (< v1.3) unencrypted secret.
657
+ */
591
658
  async getBwsJoinSecret(params: {
592
659
  /** Session ID */
593
660
  id: string;
661
+ /** Blockchain (e.g., 'btc', 'eth'). Omitted by legacy clients */
662
+ chain?: string;
663
+ /** Network (e.g., 'livenet', 'testnet'). Omitted by legacy clients */
664
+ network?: string;
594
665
  /** Copayer ID of the requesting party */
595
666
  copayerId: string;
596
667
  }): Promise<string> {
@@ -600,16 +671,42 @@ class TssKeyGenClass {
600
671
  if (!session) {
601
672
  throw Errors.TSS_SESSION_NOT_FOUND;
602
673
  }
603
- if (session.participants.indexOf(copayerId) === -1) {
674
+ const partyId = session.participants.indexOf(copayerId);
675
+ if (partyId === -1) {
604
676
  throw Errors.TSS_NON_PARTICIPANT;
605
677
  }
606
- if (!session.bwsJoinSecret) {
678
+
679
+ if (params.chain == null && params.network == null) {
680
+ // Legacy clients don't send chain & network
681
+ if (!session.bwsJoinSecret) {
682
+ throw Errors.TSS_BWS_JOIN_SECRET_NOT_FOUND;
683
+ }
684
+ return session.bwsJoinSecret;
685
+ }
686
+
687
+ // Note: sessions < v1.3 can have chain-specific secrets too (i.e. wallets created with an existing TSS key)
688
+ const { chain, network } = getSecretChainNetwork(params.chain, params.network);
689
+ const secret = session.bwsJoinSecrets?.[`${chain}:${network}`]?.[partyId];
690
+ if (typeof secret !== 'string') {
607
691
  throw Errors.TSS_BWS_JOIN_SECRET_NOT_FOUND;
608
692
  }
609
- return session.bwsJoinSecret;
693
+ return secret;
610
694
  }
611
695
  };
612
696
 
697
+ /** Validate and normalize the chain & network used to key the BWS join secrets */
698
+ function getSecretChainNetwork(chain: string, network: string) {
699
+ chain = typeof chain === 'string' ? chain.toLowerCase() : chain;
700
+ if (!Utils.checkValueInCollection(chain, Constants.CHAINS)) {
701
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid chain provided');
702
+ }
703
+ network = typeof network === 'string' ? Utils.getNetworkName(chain, Utils.getGenericName(network.toLowerCase())) : network;
704
+ if (!Utils.checkValueInCollection(network, Constants.NETWORKS[chain])) {
705
+ throw Errors.TSS_GENERIC_ERROR.withMessage('Invalid network provided');
706
+ }
707
+ return { chain, network };
708
+ }
709
+
613
710
  export const TssKeyGen = new TssKeyGenClass();
614
711
 
615
712
  class TssSignClass {
@@ -748,7 +845,8 @@ class TssSignClass {
748
845
  if (session.isExpired()) {
749
846
  throw Errors.TSS_SESSION_EXPIRED;
750
847
  }
751
- if (!this._isValidBroadcastMessage({ message }) && !this._isValidP2pMessage({ message })) {
848
+ // 1-of-N sessions won't have messages for other recipients
849
+ if (session.m > 1 && !this._isValidBroadcastMessage({ message }) && !this._isValidP2pMessage({ message })) {
752
850
  throw Errors.TSS_INVALID_MESSAGE.withMessage('Invalid message provided');
753
851
  }
754
852
 
@@ -903,8 +1001,10 @@ class TssSignClass {
903
1001
  id: string;
904
1002
  /** Signature to store */
905
1003
  signature: ITssSigMessageObject['signature'];
1004
+ /** Copayer ID of the sender */
1005
+ copayerId: string;
906
1006
  }): Promise<void> {
907
- const { id, signature } = params;
1007
+ const { id, signature, copayerId } = params;
908
1008
  if (!signature) {
909
1009
  throw Errors.TSS_NO_FINAL_SIGNATURE;
910
1010
  }
@@ -917,6 +1017,10 @@ class TssSignClass {
917
1017
  if (!session) {
918
1018
  throw Errors.TSS_SESSION_NOT_FOUND;
919
1019
  }
1020
+ const party = session.participants.find(p => p.copayerId === copayerId);
1021
+ if (!party) {
1022
+ throw Errors.TSS_NON_PARTICIPANT;
1023
+ }
920
1024
 
921
1025
  const verifySig = (sig1, sig2) => {
922
1026
  return sig1.r === sig2.r &&
@@ -13,6 +13,7 @@ import { ExpressApp } from '../src/lib/expressapp';
13
13
  import { ClientError } from '../src/lib/errors/clienterror';
14
14
  import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core';
15
15
  import * as TestData from './testdata';
16
+ import { Stats } from '../src/lib/stats';
16
17
 
17
18
  const Defaults = Common.Defaults;
18
19
  const should = chai.should();
@@ -65,6 +66,104 @@ describe('ExpressApp', function() {
65
66
  httpServer.close();
66
67
  });
67
68
 
69
+ describe('Public query validation', function() {
70
+ let runStats, getFiatRate;
71
+
72
+ beforeEach(function() {
73
+ runStats = sandbox.stub(Stats.prototype, 'run').callsArgWith(0, null, {});
74
+ getFiatRate = sinon.stub().callsArgWith(1, null, {});
75
+ sandbox.stub(WalletService, 'initialize').callsArg(1);
76
+ sandbox.stub(WalletService, 'getInstance').returns({ getFiatRate } as any);
77
+ });
78
+
79
+ const targets = [
80
+ { path: '/v1/stats/', parameter: 'network' },
81
+ { path: '/v1/stats/', parameter: 'coin' },
82
+ { path: '/v1/fiatrates/USD/', parameter: 'coin' }
83
+ ];
84
+
85
+ for (const { path, parameter } of targets) {
86
+ const value = parameter === 'network' ? 'livenet' : 'btc';
87
+ // Cover operator objects, bracket arrays, and duplicate parameters.
88
+ const invalidQueries = [
89
+ `${parameter}[$regex]=.*`,
90
+ `${parameter}[]=${value}`,
91
+ `${parameter}=${value}&${parameter}=${value}`
92
+ ];
93
+ for (const query of invalidQueries) {
94
+ it(`should reject ${path}?${query} before accessing data`, function(done) {
95
+ start(ExpressApp, function() {
96
+ request({ url: testHost + ':' + testPort + config.basePath + path + '?' + query, json: true }, function(err, res, body) {
97
+ should.not.exist(err);
98
+ res.statusCode.should.equal(400);
99
+ body.code.should.equal('BADREQUEST');
100
+ runStats.called.should.equal(false);
101
+ getFiatRate.called.should.equal(false);
102
+ done();
103
+ });
104
+ });
105
+ });
106
+ }
107
+ }
108
+
109
+ for (const query of ['', '?coin=&network=']) {
110
+ it(`should preserve stats defaults for ${query || 'omitted parameters'}`, function(done) {
111
+ start(ExpressApp, function() {
112
+ request({ url: testHost + ':' + testPort + config.basePath + '/v1/stats/' + query, json: true }, function(err, res) {
113
+ should.not.exist(err);
114
+ res.statusCode.should.equal(200);
115
+ runStats.calledOnce.should.equal(true);
116
+ runStats.firstCall.thisValue.coin.should.equal('btc');
117
+ runStats.firstCall.thisValue.network.should.equal('livenet');
118
+ done();
119
+ });
120
+ });
121
+ });
122
+ }
123
+
124
+ it('should pass valid stats filters through unchanged', function(done) {
125
+ start(ExpressApp, function() {
126
+ const query = '?coin=eth&network=testnet&from=2026-01-01&to=2026-02-01';
127
+ request({ url: testHost + ':' + testPort + config.basePath + '/v1/stats/' + query, json: true }, function(err, res) {
128
+ should.not.exist(err);
129
+ res.statusCode.should.equal(200);
130
+ runStats.calledOnce.should.equal(true);
131
+ const stats = runStats.firstCall.thisValue;
132
+ stats.coin.should.equal('eth');
133
+ stats.network.should.equal('testnet');
134
+ stats.from.should.equal('2026-01-01');
135
+ stats.to.should.equal('2026-02-01');
136
+ done();
137
+ });
138
+ });
139
+ });
140
+
141
+ for (const query of ['', '?coin=']) {
142
+ it(`should preserve the fiat coin default for ${query || 'omitted parameters'}`, function(done) {
143
+ start(ExpressApp, function() {
144
+ request({ url: testHost + ':' + testPort + config.basePath + '/v1/fiatrates/USD/' + query, json: true }, function(err, res) {
145
+ should.not.exist(err);
146
+ res.statusCode.should.equal(200);
147
+ getFiatRate.calledOnceWithExactly({ code: 'USD', coin: 'btc', ts: null }, sinon.match.func).should.equal(true);
148
+ done();
149
+ });
150
+ });
151
+ });
152
+ }
153
+
154
+ it('should pass a valid fiat coin and numeric timestamp through unchanged', function(done) {
155
+ start(ExpressApp, function() {
156
+ const query = '?coin=eth&ts=1700000000000';
157
+ request({ url: testHost + ':' + testPort + config.basePath + '/v1/fiatrates/EUR/' + query, json: true }, function(err, res) {
158
+ should.not.exist(err);
159
+ res.statusCode.should.equal(200);
160
+ getFiatRate.calledOnceWithExactly({ code: 'EUR', coin: 'eth', ts: 1700000000000 }, sinon.match.func).should.equal(true);
161
+ done();
162
+ });
163
+ });
164
+ });
165
+ });
166
+
68
167
  it('/v2/wallets', function(done) {
69
168
  const server = {
70
169
  getStatus: sinon.stub().callsArgWith(1, null, {}),
@@ -21,6 +21,7 @@ import * as TestData from '../testdata';
21
21
  import helpers from './helpers';
22
22
  import { ClientError } from '../../src/lib/errors/clienterror';
23
23
  import { ExpressApp } from '../../src/lib/expressapp';
24
+ import { type JoinWalletOpts } from '../../src/lib/server';
24
25
 
25
26
  const should = chai.should();
26
27
  config.moralis = config.moralis ?? {
@@ -1120,7 +1121,7 @@ describe('Wallet service', function() {
1120
1121
  xPubKey: TestData.copayers[0].xPubKey_44H_0H_0H,
1121
1122
  requestPubKey: TestData.copayers[0].pubKey_1H_0,
1122
1123
  };
1123
- server.joinWallet(copayerOpts, function(err) {
1124
+ server.joinWallet(copayerOpts as JoinWalletOpts, function(err) {
1124
1125
  should.exist(err);
1125
1126
  err.message.should.contain('argument: copayerSignature missing');
1126
1127
  done();
@@ -1774,6 +1775,57 @@ describe('Wallet service', function() {
1774
1775
  wallet.copayers.length.should.equal(0);
1775
1776
  });
1776
1777
  });
1778
+
1779
+ describe('TSS wallets (concurrent join)', function() {
1780
+ it('should keep a copayer that joins while the wallet is being registered', async function() {
1781
+ const server = new WalletService();
1782
+ const copayerIds = [0, 1].map(i => Model.Copayer.xPubToCopayerId('btc', TestData.copayers[i].xPubKey_44H_0H_0H));
1783
+
1784
+ const session = TssKeyGenModel.create({
1785
+ id: 'tss-join-during-register-test-session',
1786
+ message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 },
1787
+ n: 2,
1788
+ copayerId: copayerIds[0],
1789
+ version: Defaults.TSS_KEYGEN_SCHEME_VERSION
1790
+ });
1791
+ session.participants[1] = copayerIds[1];
1792
+ session.sharedPublicKey = 'dummy-shared-public-key';
1793
+ await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });
1794
+ await server.storage.storeTssKeyGenSession({ doc: session });
1795
+
1796
+ const walletId = await util.promisify(server.createWallet).call(server, {
1797
+ name: 'tss wallet',
1798
+ m: 1,
1799
+ n: 1,
1800
+ pubKey: TestData.keyPair.pub,
1801
+ coin: 'btc',
1802
+ tssKeyId: session.id,
1803
+ });
1804
+ const [creatorOpts, joinerOpts] = [0, 1].map(i => helpers.getSignedCopayerOpts({
1805
+ walletId,
1806
+ name: 'copayer ' + (i + 1),
1807
+ xPubKey: TestData.copayers[i].xPubKey_44H_0H_0H,
1808
+ requestPubKey: TestData.copayers[i].pubKey_1H_0,
1809
+ }));
1810
+ const { copayerId } = await util.promisify(server.joinWallet).call(server, creatorOpts);
1811
+ const creatorServer = await helpers.getAuthServer(copayerId);
1812
+ creatorServer.checkWalletSync = function(a, b, simple, cb) {
1813
+ if (simple) return cb(null, false);
1814
+ return cb(null, true);
1815
+ };
1816
+
1817
+ blockchainExplorer.register = sinon.stub().callsFake((wallet, cb) => server.joinWallet(joinerOpts, cb));
1818
+ await util.promisify(creatorServer.getStatus).call(creatorServer, { includeExtendedInfo: true });
1819
+
1820
+ blockchainExplorer.register.calledOnce.should.equal(true);
1821
+ const registeredWallet = blockchainExplorer.register.firstCall.args[0];
1822
+ const wallet = await util.promisify(server.storage.fetchWallet).call(server.storage, walletId);
1823
+ wallet.copayers.length.should.equal(2);
1824
+ wallet.beRegistered.should.equal(true);
1825
+ wallet.beAuthPrivateKey2.should.equal(registeredWallet.beAuthPrivateKey2);
1826
+ wallet.beAuthPublicKey2.should.equal(registeredWallet.beAuthPublicKey2);
1827
+ });
1828
+ });
1777
1829
  });
1778
1830
 
1779
1831
  describe('#removeWallet', function() {