Skip to content

Commit 99d42cd

Browse files
authored
Merge pull request #230 from BitGo/WCN-769/recoveryWallet-integ-test
test: add integration tests for recovery wallet
2 parents 475942f + 901fc2b commit 99d42cd

3 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import nock from 'nock';
2+
3+
const BLOCKCHAIR_BASE = 'https://api.blockchair.com';
4+
const MEMPOOL_BASE = 'https://mempool.space';
5+
6+
export interface IndexerMockOptions {
7+
fundsAddress: string;
8+
txHash: string;
9+
value: number;
10+
apiKey: string;
11+
}
12+
13+
export interface IndexerMocks {
14+
fundsBalanceDone(): boolean;
15+
unspentsDone(): boolean;
16+
feeDone(): boolean;
17+
}
18+
19+
export function setupIndexerMocks(opts: IndexerMockOptions): IndexerMocks {
20+
const { fundsAddress, txHash, value, apiKey } = opts;
21+
22+
const balanceNock = nock(BLOCKCHAIR_BASE)
23+
.get(`/bitcoin/testnet/dashboards/address/${fundsAddress}?key=${apiKey}`)
24+
.reply(200, {
25+
data: { [fundsAddress]: { address: { transaction_count: 1, balance: value } } },
26+
});
27+
28+
const unspentsNock = nock(BLOCKCHAIR_BASE)
29+
.get(`/bitcoin/testnet/dashboards/addresses/${fundsAddress}?key=${apiKey}`)
30+
.reply(200, {
31+
data: {
32+
utxo: [
33+
{
34+
transaction_hash: txHash,
35+
index: 0,
36+
recipient: fundsAddress,
37+
value,
38+
block_id: 100,
39+
spending_transaction_hash: null,
40+
spending_index: null,
41+
address: fundsAddress,
42+
},
43+
],
44+
},
45+
});
46+
47+
nock(BLOCKCHAIR_BASE)
48+
.persist()
49+
.get(new RegExp(`/bitcoin/testnet/dashboards/address/[^?]+\\?key=${apiKey}`))
50+
.reply(function (uri) {
51+
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
52+
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
53+
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
54+
});
55+
56+
const feeNock = nock(MEMPOOL_BASE)
57+
.get('/api/v1/fees/recommended')
58+
.reply(200, { fastestFee: 20, halfHourFee: 10, hourFee: 5 });
59+
60+
return {
61+
fundsBalanceDone: () => balanceNock.isDone(),
62+
unspentsDone: () => unspentsNock.isDone(),
63+
feeDone: () => feeNock.isDone(),
64+
};
65+
}
66+
67+
export function teardownIndexerMocks(): void {
68+
nock.cleanAll();
69+
}

src/__tests__/integration/helpers/setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ export interface IntegServices {
1616

1717
export interface StartServicesOptions {
1818
signingMode?: SigningMode;
19+
recoveryMode?: boolean;
1920
}
2021

2122
export async function startServices(opts: StartServicesOptions = {}): Promise<IntegServices> {
2223
const signingMode = opts.signingMode ?? SigningMode.LOCAL;
24+
const recoveryMode = opts.recoveryMode ?? false;
2325

2426
const keyProvider = await startMockKeyProviderServer();
2527
const bitgo = await startMockBitgoServer();
@@ -34,6 +36,7 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
3436
timeout: 30000,
3537
httpLoggerFile: '',
3638
keyProviderUrl: `http://${LOCALHOST}:${keyProvider.port}`,
39+
recoveryMode,
3740
}),
3841
);
3942
const awmPort = await listen(awmServer);
@@ -52,6 +55,7 @@ export async function startServices(opts: StartServicesOptions = {}): Promise<In
5255
awmServerCertAllowSelfSigned: true,
5356
customRootUri: `http://${LOCALHOST}:${bitgo.port}`,
5457
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
58+
recoveryMode,
5559
}),
5660
);
5761
const mbePort = await listen(mbeServer);
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import 'should';
2+
import nock from 'nock';
3+
import signFixture from './fixtures/keyProvider/sign.json';
4+
import { startServices, IntegServices } from './helpers/setup';
5+
import { LOCALHOST } from './helpers/servers';
6+
import { setupIndexerMocks, teardownIndexerMocks } from './helpers/mockIndexerServer';
7+
import { SigningMode } from '../../shared/types';
8+
9+
/**
10+
* Deterministic test keypairs derived from Buffer.alloc(64, 0x11/0x22/0x33).
11+
* Not secrets. Never funded.
12+
*/
13+
const USER_XPUB =
14+
'xpub661MyMwAqRbcG6vAfdAptEwUcELwQDcsPnBtAJoPSKc8nQC7QniowQJq5iLHFWxrPX11bjBzohqh7isG6rgqBdMa7hPCUekfc6XbER5AH4A';
15+
const USER_XPRV =
16+
'xprv9s21ZrQH143K3cqhZbdpX6zk4CWSzku22ZGHMvPmsz59ubrxsFQZPbzMESXYNBC1xkjtLfcfE6nDHHqwuxtiDD2LUjxrJ642wbV1LptE2TY';
17+
const BACKUP_XPUB =
18+
'xpub661MyMwAqRbcFnihegj1Mo2ePZoMQyLbBYpW7gDXZ7qzqxF3FBAkNAP8Gki8Mxx2BVLjN3RRa75pt5apD2g3ewXPrCfdssAJ7VupXqucLsb';
19+
const BACKUP_XPRV =
20+
'xprv9s21ZrQH143K3JeEYfBzzf5uqXxs1WcjpKtuKHouznK1y9uthdrVpN4eRT6DazfjqrUzt4Sgb3CJoYMov84hCupCxUpR5AKEgCcqwsEw8D7';
21+
const BITGO_XPUB =
22+
'xpub661MyMwAqRbcGzf9nbW39NHXwK34zP3q2ZxcwKv1A29u2fdkZJLG8tfuthR5YL91p85QECEJBK1oTs2fmeToKuiSBBLMDi49wYh1SzSQ1WN';
23+
24+
const ADDR_WITH_FUNDS = 'tb1qjtxnrfcjkhwtghy2nphshch688zzepmnj2927nlazfc77xcnax0qlwp3je';
25+
const MOCK_UTXO_TX_HASH = '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed';
26+
const MOCK_UTXO_VALUE = 4000;
27+
28+
const KP_SIGN_FIXTURE = signFixture.signature;
29+
const BLOCKCHAIR_API_KEY = 'test-key';
30+
31+
const recoveryRequestBody = {
32+
multiSigRecoveryParams: {
33+
userPub: USER_XPUB,
34+
backupPub: BACKUP_XPUB,
35+
bitgoPub: BITGO_XPUB,
36+
walletContractAddress: '',
37+
},
38+
recoveryDestinationAddress: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
39+
coin: 'tbtc',
40+
apiKey: BLOCKCHAIR_API_KEY,
41+
coinSpecificParams: {
42+
utxoRecoveryOptions: {
43+
scan: 1,
44+
},
45+
},
46+
};
47+
48+
interface RecoveryResponse {
49+
txHex: string;
50+
}
51+
52+
interface SignCallBody {
53+
pub: string;
54+
source: string;
55+
signablePayload: string;
56+
algorithm: string;
57+
}
58+
59+
interface KeyBody {
60+
pub: string;
61+
prv: string;
62+
coin: string;
63+
source: string;
64+
type: string;
65+
}
66+
67+
describe('Recovery wallet: EXTERNAL signing', () => {
68+
let services: IntegServices;
69+
70+
before(async () => {
71+
services = await startServices({ signingMode: SigningMode.EXTERNAL, recoveryMode: true });
72+
nock.disableNetConnect();
73+
nock.enableNetConnect('127.0.0.1');
74+
});
75+
76+
after(async () => {
77+
teardownIndexerMocks();
78+
nock.enableNetConnect();
79+
await services.teardown();
80+
});
81+
82+
beforeEach(() => {
83+
services.keyProvider.calls.length = 0;
84+
services.bitgo.calls.length = 0;
85+
});
86+
87+
afterEach(() => {
88+
teardownIndexerMocks();
89+
});
90+
91+
it('recovers tbtc via external key provider, calling AWM multisig/recovery', async () => {
92+
const indexer = setupIndexerMocks({
93+
fundsAddress: ADDR_WITH_FUNDS,
94+
txHash: MOCK_UTXO_TX_HASH,
95+
value: MOCK_UTXO_VALUE,
96+
apiKey: BLOCKCHAIR_API_KEY,
97+
});
98+
99+
const res = await fetch(
100+
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/recovery`,
101+
{
102+
method: 'POST',
103+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
104+
body: JSON.stringify(recoveryRequestBody),
105+
},
106+
);
107+
108+
res.status.should.equal(200);
109+
const body = (await res.json()) as RecoveryResponse;
110+
body.should.have.property('txHex');
111+
112+
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0);
113+
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(0);
114+
services.bitgo.calls
115+
.filter((c) => c.path.endsWith('/consolidateUnspents'))
116+
.should.have.length(0);
117+
118+
const signCalls = services.keyProvider.calls.filter((c) => c.path === '/sign');
119+
signCalls.should.have.length(2);
120+
121+
const userSignBody = signCalls[0].body as SignCallBody;
122+
userSignBody.should.have.property('pub', USER_XPUB);
123+
userSignBody.should.have.property('source', 'user');
124+
userSignBody.should.have.property('algorithm', 'ecdsa');
125+
userSignBody.signablePayload.should.startWith('70736274ff');
126+
127+
const backupSignBody = signCalls[1].body as SignCallBody;
128+
backupSignBody.should.have.property('pub', BACKUP_XPUB);
129+
backupSignBody.should.have.property('source', 'backup');
130+
backupSignBody.should.have.property('algorithm', 'ecdsa');
131+
backupSignBody.should.have.property('signablePayload', KP_SIGN_FIXTURE);
132+
133+
services.keyProvider.calls.filter((c) => c.path === '/key').should.have.length(0);
134+
services.keyProvider.calls.filter((c) => c.path.startsWith('/key/')).should.have.length(0);
135+
136+
body.txHex.should.equal(KP_SIGN_FIXTURE);
137+
138+
indexer.fundsBalanceDone().should.be.true();
139+
indexer.unspentsDone().should.be.true();
140+
indexer.feeDone().should.be.true();
141+
});
142+
});
143+
144+
describe('Recovery wallet: LOCAL signing', () => {
145+
let services: IntegServices;
146+
147+
before(async () => {
148+
services = await startServices({ signingMode: SigningMode.LOCAL, recoveryMode: true });
149+
150+
await fetch(`http://127.0.0.1:${services.keyProvider.port}/key`, {
151+
method: 'POST',
152+
headers: { 'Content-Type': 'application/json' },
153+
body: JSON.stringify({
154+
pub: USER_XPUB,
155+
prv: USER_XPRV,
156+
coin: 'tbtc',
157+
source: 'user',
158+
type: 'independent',
159+
} satisfies KeyBody),
160+
});
161+
162+
await fetch(`http://127.0.0.1:${services.keyProvider.port}/key`, {
163+
method: 'POST',
164+
headers: { 'Content-Type': 'application/json' },
165+
body: JSON.stringify({
166+
pub: BACKUP_XPUB,
167+
prv: BACKUP_XPRV,
168+
coin: 'tbtc',
169+
source: 'backup',
170+
type: 'independent',
171+
} satisfies KeyBody),
172+
});
173+
174+
nock.disableNetConnect();
175+
nock.enableNetConnect('127.0.0.1');
176+
});
177+
178+
after(async () => {
179+
teardownIndexerMocks();
180+
nock.enableNetConnect();
181+
await services.teardown();
182+
});
183+
184+
beforeEach(() => {
185+
services.keyProvider.calls.length = 0;
186+
services.bitgo.calls.length = 0;
187+
});
188+
189+
afterEach(() => {
190+
teardownIndexerMocks();
191+
});
192+
193+
it('recovers tbtc using locally stored xprvs, calling AWM multisig/recovery', async () => {
194+
const indexer = setupIndexerMocks({
195+
fundsAddress: ADDR_WITH_FUNDS,
196+
txHash: MOCK_UTXO_TX_HASH,
197+
value: MOCK_UTXO_VALUE,
198+
apiKey: BLOCKCHAIR_API_KEY,
199+
});
200+
201+
const res = await fetch(
202+
`http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/recovery`,
203+
{
204+
method: 'POST',
205+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
206+
body: JSON.stringify(recoveryRequestBody),
207+
},
208+
);
209+
210+
res.status.should.equal(200);
211+
const body = (await res.json()) as RecoveryResponse;
212+
body.should.have.property('txHex');
213+
body.txHex.should.match(/^(01000000|02000000)/);
214+
215+
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/build')).should.have.length(0);
216+
services.bitgo.calls.filter((c) => c.path.endsWith('/tx/send')).should.have.length(0);
217+
services.bitgo.calls
218+
.filter((c) => c.path.endsWith('/consolidateUnspents'))
219+
.should.have.length(0);
220+
221+
services.keyProvider.calls.filter((c) => c.path === '/sign').should.have.length(0);
222+
223+
const keyLookups = services.keyProvider.calls.filter((c) => c.path.startsWith('/key/'));
224+
keyLookups.should.have.length(2);
225+
const lookedUpPubs = keyLookups.map((c) => c.path.replace('/key/', ''));
226+
lookedUpPubs.should.containEql(USER_XPUB);
227+
lookedUpPubs.should.containEql(BACKUP_XPUB);
228+
229+
indexer.fundsBalanceDone().should.be.true();
230+
indexer.unspentsDone().should.be.true();
231+
indexer.feeDone().should.be.true();
232+
});
233+
});

0 commit comments

Comments
 (0)