Skip to content

Commit c17aca3

Browse files
Merge pull request #47 from BitGo/WP-4736/utxo-recoveries
feat(mbe,ebe): utxo recoveries
2 parents 49c1cfa + a354959 commit c17aca3

File tree

11 files changed

+702
-64
lines changed

11 files changed

+702
-64
lines changed

masterBitgoExpress.json

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@
410410
"backupPub": {
411411
"type": "string"
412412
},
413+
"bitgoPub": {
414+
"type": "string"
415+
},
413416
"walletContractAddress": {
414417
"type": "string"
415418
},
@@ -422,13 +425,23 @@
422425
"coinSpecificParams": {
423426
"type": "object",
424427
"properties": {
425-
"bitgoPub": {
426-
"type": "string"
428+
"addressScan": {
429+
"type": "number"
430+
},
431+
"feeRate": {
432+
"type": "number"
427433
},
428434
"ignoreAddressTypes": {
429435
"type": "array",
430436
"items": {
431-
"type": "string"
437+
"type": "string",
438+
"enum": [
439+
"p2sh",
440+
"p2shP2wsh",
441+
"p2wsh",
442+
"p2tr",
443+
"p2trMusig2"
444+
]
432445
}
433446
}
434447
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
"@api-ts/typed-express-router": "^1.1.13",
2727
"@bitgo/sdk-core": "^35.3.0",
2828
"bitgo": "^48.1.0",
29+
"@bitgo/abstract-utxo": "^9.21.4",
30+
"@bitgo/statics": "^54.6.0",
2931
"body-parser": "^1.20.3",
3032
"connect-timeout": "^1.9.0",
3133
"debug": "^3.1.0",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import 'should';
2+
import * as request from 'supertest';
3+
import nock from 'nock';
4+
import { app as expressApp } from '../../../enclavedApp';
5+
import { AppMode, EnclavedConfig, TlsMode } from '../../../shared/types';
6+
import sinon from 'sinon';
7+
import * as middleware from '../../../shared/middleware';
8+
import { BitGoRequest } from '../../../types/request';
9+
import { BitGo } from 'bitgo';
10+
import * as kmsUtils from '../../../api/enclaved/utils';
11+
12+
describe('UTXO recovery', () => {
13+
let agent: request.SuperAgentTest;
14+
let mockBitgo: BitGo;
15+
let mockRetrieveKmsPrvKey: sinon.SinonStub;
16+
const coin = 'tbtc';
17+
const config: EnclavedConfig = {
18+
appMode: AppMode.ENCLAVED,
19+
port: 0,
20+
bind: 'localhost',
21+
timeout: 60000,
22+
logFile: '',
23+
tlsMode: TlsMode.DISABLED,
24+
mtlsRequestCert: false,
25+
allowSelfSigned: true,
26+
kmsUrl: 'kms.example.com',
27+
};
28+
29+
beforeEach(() => {
30+
nock.disableNetConnect();
31+
nock.enableNetConnect('127.0.0.1');
32+
33+
// Initialize BitGo with test environment
34+
const bitgo = new BitGo({
35+
env: 'test',
36+
accessToken: 'test_token',
37+
});
38+
39+
// Create mock BitGo instance
40+
mockBitgo = {
41+
_coinFactory: {},
42+
_useAms: false,
43+
initCoinFactory: sinon.stub(),
44+
coin: bitgo.coin.bind(bitgo), // Use the real coin method from initialized BitGo
45+
} as unknown as BitGo;
46+
47+
// Setup middleware stubs before creating app
48+
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
49+
(req as BitGoRequest<EnclavedConfig>).bitgo = mockBitgo;
50+
(req as BitGoRequest<EnclavedConfig>).config = config;
51+
next();
52+
});
53+
54+
// Mock KMS key retrieval
55+
mockRetrieveKmsPrvKey = sinon.stub(kmsUtils, 'retrieveKmsPrvKey');
56+
mockRetrieveKmsPrvKey
57+
.withArgs({
58+
pub: 'xpub661MyMwAqRbcF3g1sUm7T5pN8ViCr9bS6XiQbq7dVXFdPEGYfhGgjjV2AFxTYVWik29y7NHmCZjWYDkt4RGw57HNYpHnoHeeqJV6s8hwcsV',
59+
source: 'user',
60+
cfg: config,
61+
})
62+
.resolves(
63+
'xprv9s21ZrQH143K2ZbYmTE75wsdaTsiSgsajJnooSi1wBieWRwQ89xSBwAYK1VJR795Y8XFCCXYHHs4sk2Heg6dkX3CHMBq5bw8DwBWByWx883',
64+
);
65+
66+
mockRetrieveKmsPrvKey
67+
.withArgs({
68+
pub: 'xpub661MyMwAqRbcEywGPF6Pg1FDUtHGyxsn7nph8dcy8GFLKvQ8hSCKgUm8sNbJhegDbmLtMpMnGZtrqfRXCjeDtfJ2UGDSzNTkRuvAQ5KNPcH',
69+
source: 'backup',
70+
cfg: config,
71+
})
72+
.resolves(
73+
'xprv9s21ZrQH143K2VroHDZPJsJUvrSnaW9vkZu6LFDMZviMT84z9tt58gSf25PzAMJC9pb1qRUBiYcsgcKWTDhwmwazsDAvzzDB5qrE3XDfawH',
74+
);
75+
76+
// Create app after middleware is stubbed
77+
const app = expressApp(config);
78+
agent = request.agent(app);
79+
});
80+
81+
afterEach(() => {
82+
nock.cleanAll();
83+
sinon.restore();
84+
});
85+
86+
it('should recover a UTXO wallet by signing with user and backup keys', async () => {
87+
const userPub =
88+
'xpub661MyMwAqRbcF3g1sUm7T5pN8ViCr9bS6XiQbq7dVXFdPEGYfhGgjjV2AFxTYVWik29y7NHmCZjWYDkt4RGw57HNYpHnoHeeqJV6s8hwcsV';
89+
const backupPub =
90+
'xpub661MyMwAqRbcEywGPF6Pg1FDUtHGyxsn7nph8dcy8GFLKvQ8hSCKgUm8sNbJhegDbmLtMpMnGZtrqfRXCjeDtfJ2UGDSzNTkRuvAQ5KNPcH';
91+
const bitgoPub =
92+
'xpub661MyMwAqRbcGcBurxn9ptqqKGmMhnKa8D7TeZkaWpfQNTeG4qKEJ67eb6Hy58kZBwPHqjUt5iApUwvFVk9ffQYaV42RRom2p7yU5bcCwpq';
93+
94+
const unsignedSweepPrebuildTx = {
95+
txHex:
96+
'0100000001edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f417027900000000',
97+
txInfo: {
98+
unspents: [
99+
{
100+
id: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed:0',
101+
address: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
102+
value: 4000,
103+
chain: 20,
104+
index: 0,
105+
valueString: '4000',
106+
},
107+
],
108+
},
109+
feeInfo: {},
110+
coin: 'tbtc',
111+
};
112+
113+
const response = await agent.post(`/api/${coin}/multisig/recovery`).send({
114+
userPub,
115+
backupPub,
116+
bitgoPub,
117+
unsignedSweepPrebuildTx,
118+
walletContractAddress: '',
119+
coin,
120+
});
121+
122+
response.status.should.equal(200);
123+
response.body.should.have.property('txHex');
124+
response.body.txHex.should.equal(
125+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000',
126+
);
127+
128+
// Verify KMS key retrieval
129+
mockRetrieveKmsPrvKey
130+
.calledWith({
131+
pub: userPub,
132+
source: 'user',
133+
cfg: config,
134+
})
135+
.should.be.true();
136+
mockRetrieveKmsPrvKey
137+
.calledWith({
138+
pub: backupPub,
139+
source: 'backup',
140+
cfg: config,
141+
})
142+
.should.be.true();
143+
});
144+
});
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import 'should';
2+
import * as request from 'supertest';
3+
import nock from 'nock';
4+
import { app as expressApp } from '../../../masterExpressApp';
5+
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
6+
import sinon from 'sinon';
7+
import * as middleware from '../../../shared/middleware';
8+
import * as masterMiddleware from '../../../api/master/middleware/middleware';
9+
import { BitGoRequest } from '../../../types/request';
10+
import { BitGo } from 'bitgo';
11+
import { EnclavedExpressClient } from '../../../api/master/clients/enclavedExpressClient';
12+
import { CoinFamily } from '@bitgo/statics';
13+
14+
describe('utxo recovery', () => {
15+
let agent: request.SuperAgentTest;
16+
let mockBitgo: BitGo;
17+
let mockRecover: sinon.SinonStub;
18+
let mockIsValidPub: sinon.SinonStub;
19+
let coinStub: sinon.SinonStub;
20+
let mockRecoverResponse: any;
21+
const enclavedExpressUrl = 'http://enclaved.invalid';
22+
const coin = 'tbtc';
23+
const accessToken = 'test-token';
24+
const config: MasterExpressConfig = {
25+
appMode: AppMode.MASTER_EXPRESS,
26+
port: 0,
27+
bind: 'localhost',
28+
timeout: 60000,
29+
logFile: '',
30+
env: 'test',
31+
disableEnvCheck: true,
32+
authVersion: 2,
33+
enclavedExpressUrl: enclavedExpressUrl,
34+
enclavedExpressCert: 'dummy-cert',
35+
tlsMode: TlsMode.DISABLED,
36+
mtlsRequestCert: false,
37+
allowSelfSigned: true,
38+
};
39+
40+
beforeEach(() => {
41+
nock.disableNetConnect();
42+
nock.enableNetConnect('127.0.0.1');
43+
44+
// Setup mock response
45+
mockRecoverResponse = {
46+
txHex:
47+
'0100000001edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f417027900000000',
48+
txInfo: {
49+
unspents: [
50+
{
51+
id: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed:0',
52+
address: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
53+
value: 4000,
54+
chain: 20,
55+
index: 0,
56+
valueString: '4000',
57+
},
58+
],
59+
},
60+
feeInfo: {},
61+
coin: 'tbtc',
62+
};
63+
64+
// Create mock methods
65+
mockRecover = sinon.stub().resolves(mockRecoverResponse);
66+
mockIsValidPub = sinon.stub().returns(true);
67+
const mockCoin = {
68+
recover: mockRecover,
69+
isValidPub: mockIsValidPub,
70+
getFamily: sinon.stub().returns(CoinFamily.BTC),
71+
};
72+
coinStub = sinon.stub().returns(mockCoin);
73+
74+
// Create mock BitGo instance
75+
mockBitgo = {
76+
coin: coinStub,
77+
_coinFactory: {},
78+
_useAms: false,
79+
initCoinFactory: sinon.stub(),
80+
registerToken: sinon.stub(),
81+
getValidate: sinon.stub(),
82+
validateAddress: sinon.stub(),
83+
verifyAddress: sinon.stub(),
84+
verifyPassword: sinon.stub(),
85+
encrypt: sinon.stub(),
86+
decrypt: sinon.stub(),
87+
lock: sinon.stub(),
88+
unlock: sinon.stub(),
89+
getSharingKey: sinon.stub(),
90+
ping: sinon.stub(),
91+
authenticate: sinon.stub(),
92+
authenticateWithAccessToken: sinon.stub(),
93+
logout: sinon.stub(),
94+
me: sinon.stub(),
95+
session: sinon.stub(),
96+
getUser: sinon.stub(),
97+
users: sinon.stub(),
98+
getWallet: sinon.stub(),
99+
getWallets: sinon.stub(),
100+
addWallet: sinon.stub(),
101+
removeWallet: sinon.stub(),
102+
getAsUser: sinon.stub(),
103+
} as unknown as BitGo;
104+
105+
// Setup middleware stubs before creating app
106+
sinon.stub(middleware, 'prepareBitGo').callsFake(() => (req, res, next) => {
107+
(req as BitGoRequest<MasterExpressConfig>).bitgo = mockBitgo;
108+
(req as BitGoRequest<MasterExpressConfig>).config = config;
109+
next();
110+
});
111+
112+
sinon.stub(masterMiddleware, 'validateMasterExpressConfig').callsFake((req, res, next) => {
113+
(req as BitGoRequest<MasterExpressConfig>).params = { coin };
114+
(req as BitGoRequest<MasterExpressConfig>).enclavedExpressClient = new EnclavedExpressClient(
115+
config,
116+
coin,
117+
);
118+
next();
119+
return undefined;
120+
});
121+
122+
// Create app after middleware is stubbed
123+
const app = expressApp(config);
124+
agent = request.agent(app);
125+
});
126+
127+
afterEach(() => {
128+
nock.cleanAll();
129+
sinon.restore();
130+
});
131+
132+
it('should recover a UTXO wallet by calling the enclaved express service', async () => {
133+
const userPub = 'xpub_user';
134+
const backupPub = 'xpub_backup';
135+
const bitgoPub = 'xpub_bitgo';
136+
const recoveryDestination = 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu';
137+
138+
// Mock the enclaved express recovery call
139+
const recoveryNock = nock(enclavedExpressUrl)
140+
.post(`/api/${coin}/multisig/recovery`, {
141+
userPub,
142+
backupPub,
143+
bitgoPub,
144+
unsignedSweepPrebuildTx: mockRecoverResponse,
145+
walletContractAddress: '',
146+
})
147+
.reply(200, {
148+
txHex:
149+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000',
150+
});
151+
152+
const response = await agent
153+
.post(`/api/${coin}/wallet/recovery`)
154+
.set('Authorization', `Bearer ${accessToken}`)
155+
.send({
156+
userPub,
157+
backupPub,
158+
bitgoPub,
159+
recoveryDestinationAddress: recoveryDestination,
160+
walletContractAddress: '',
161+
coin,
162+
apiKey: 'key',
163+
coinSpecificParams: {
164+
addressScan: 1,
165+
},
166+
});
167+
168+
response.status.should.equal(200);
169+
response.body.should.have.property('txHex');
170+
response.body.txHex.should.equal(
171+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000',
172+
);
173+
174+
// Verify SDK coin method calls
175+
coinStub.calledWith(coin).should.be.true();
176+
mockIsValidPub.calledWith(userPub).should.be.true();
177+
mockIsValidPub.calledWith(backupPub).should.be.true();
178+
mockRecover
179+
.calledWith({
180+
userKey: userPub,
181+
backupKey: backupPub,
182+
bitgoKey: bitgoPub,
183+
recoveryDestination: recoveryDestination,
184+
apiKey: 'key',
185+
ignoreAddressTypes: [],
186+
scan: 1,
187+
feeRate: undefined,
188+
})
189+
.should.be.true();
190+
191+
// Verify enclaved express call
192+
recoveryNock.done();
193+
});
194+
});

0 commit comments

Comments
 (0)