Skip to content

Commit 87588a6

Browse files
authored
Merge pull request #208 from BitGo/WCN-544
feat: add ETH support for multisign in external mode
2 parents 46c5637 + cabb3a0 commit 87588a6

9 files changed

Lines changed: 9971 additions & 1431 deletions

File tree

.eslintrc.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ module.exports = {
88
rules: {
99
'@typescript-eslint/explicit-function-return-type': 'off',
1010
'@typescript-eslint/no-explicit-any': 'off',
11-
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
11+
'@typescript-eslint/no-unused-vars': [
12+
'error',
13+
{
14+
argsIgnorePattern: '^_',
15+
varsIgnorePattern: '^_',
16+
caughtErrorsIgnorePattern: '^_',
17+
},
18+
],
1219
},
1320
ignorePatterns: ['dist/**/*', 'node_modules/**/*'],
1421
};

package-lock.json

Lines changed: 9688 additions & 1402 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929
"@api-ts/superagent-wrapper": "^1.3.3",
3030
"@api-ts/typed-express-router": "2.0.0",
3131
"@bitgo-beta/abstract-cosmos": "1.0.1-beta.1741",
32-
"@bitgo-beta/abstract-eth": "1.0.2-beta.1990",
33-
"@bitgo-beta/abstract-utxo": "1.1.1-beta.1993",
34-
"@bitgo-beta/sdk-api": "1.10.1-beta.1759",
32+
"@bitgo-beta/abstract-eth": "1.0.2-beta.2004",
33+
"@bitgo-beta/abstract-utxo": "1.1.1-beta.2007",
34+
"@bitgo-beta/sdk-api": "1.10.1-beta.1773",
3535
"@bitgo-beta/sdk-coin-ada": "2.3.14-beta.1758",
3636
"@bitgo-beta/sdk-coin-algo": "2.8.9-beta.238",
3737
"@bitgo-beta/sdk-coin-apt": "1.0.1-beta.1200",
@@ -59,8 +59,8 @@
5959
"@bitgo-beta/sdk-coin-dot": "2.2.8-beta.1756",
6060
"@bitgo-beta/sdk-coin-eos": "1.3.19-beta.1754",
6161
"@bitgo-beta/sdk-coin-etc": "1.0.2-beta.1982",
62-
"@bitgo-beta/sdk-coin-eth": "4.4.1-beta.1754",
63-
"@bitgo-beta/sdk-coin-ethw": "20.0.76-beta.921",
62+
"@bitgo-beta/sdk-coin-eth": "4.4.1-beta.1768",
63+
"@bitgo-beta/sdk-coin-ethw": "20.0.76-beta.935",
6464
"@bitgo-beta/sdk-coin-flr": "1.0.1-beta.1098",
6565
"@bitgo-beta/sdk-coin-hash": "1.0.1-beta.1714",
6666
"@bitgo-beta/sdk-coin-hbar": "1.0.2-beta.1984",
@@ -99,9 +99,9 @@
9999
"@bitgo-beta/sdk-coin-zec": "1.1.1-beta.1984",
100100
"@bitgo-beta/sdk-coin-zeta": "1.0.1-beta.1675",
101101
"@bitgo-beta/sdk-coin-zketh": "1.0.1-beta.1540",
102-
"@bitgo-beta/sdk-core": "8.2.1-beta.1760",
103-
"@bitgo-beta/sdk-lib-mpc": "8.2.0-beta.1755",
104-
"@bitgo-beta/statics": "15.1.1-beta.1767",
102+
"@bitgo-beta/sdk-core": "8.2.1-beta.1775",
103+
"@bitgo-beta/sdk-lib-mpc": "8.2.0-beta.1770",
104+
"@bitgo-beta/statics": "15.1.1-beta.1782",
105105
"@bitgo/wasm-miniscript": "2.0.0-beta.7",
106106
"@commitlint/config-conventional": "^19.8.1",
107107
"@ethereumjs/tx": "^3.3.0",
@@ -120,6 +120,8 @@
120120
"zod": "^3.25.48"
121121
},
122122
"overrides": {
123+
"@bitgo-beta/sdk-core": "8.2.1-beta.1775",
124+
"@bitgo-beta/statics": "15.1.1-beta.1782",
123125
"elliptic": "^6.6.1",
124126
"expo": "^48.0.0",
125127
"form-data": "^4.0.4",

src/__tests__/api/advancedWalletManager/postIndependentKey.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ describe('postIndependentKey — external signing mode', () => {
132132
keychains: () => ({ create: sinon.stub().returns({ pub: 'xpub...', prv: 'xprv...' }) }),
133133
} as unknown as BaseCoin;
134134

135+
const unsupportedExternalCoinStub = {
136+
getFamily: () => CoinFamily.XRP,
137+
getFullName: () => 'Test XRP',
138+
keychains: () => ({ create: sinon.stub().returns({ pub: 'xpub...', prv: 'xprv...' }) }),
139+
} as unknown as BaseCoin;
140+
135141
before(() => {
136142
nock.disableNetConnect();
137143
nock.enableNetConnect('127.0.0.1');
@@ -190,8 +196,26 @@ describe('postIndependentKey — external signing mode', () => {
190196
createSpy.called.should.equal(false);
191197
});
192198

193-
it('should fall through to local path for non-UTXO coin in external mode', async () => {
199+
it('should call POST /key/generate for ETH coin and not call POST /key', async () => {
194200
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(nonUtxoCoinStub);
201+
const externalKeyGeneratorNock = nock(keyProviderUrl)
202+
.post('/key/generate', { coin: 'hteth', source: 'user', type: 'independent' })
203+
.reply(200, { ...mockGenerateKeyResponse, coin: 'hteth' });
204+
const localKeyGeneratorNock = nock(keyProviderUrl).post('/key').reply(200, {});
205+
206+
const response = await agent
207+
.post(`/api/hteth/key/independent`)
208+
.set('Authorization', `Bearer ${accessToken}`)
209+
.send({ source: 'user' });
210+
211+
response.status.should.equal(200);
212+
response.body.should.have.property('pub', mockGenerateKeyResponse.pub);
213+
externalKeyGeneratorNock.done();
214+
localKeyGeneratorNock.isDone().should.equal(false);
215+
});
216+
217+
it('should fall through to local path for unsupported external coin in external mode', async () => {
218+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(unsupportedExternalCoinStub);
195219
const externalKeyGeneratorNock = nock(keyProviderUrl).post('/key/generate').reply(200, {});
196220
nock(keyProviderUrl).post('/key').reply(200, mockGenerateKeyResponse);
197221

src/__tests__/api/advancedWalletManager/signMultisigTransaction.test.ts

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,9 @@ describe('signMultisigTransaction — external signing mode', () => {
181181
getFullName: () => 'Test Bitcoin',
182182
} as unknown as BaseCoin;
183183

184-
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(utxoCoinStub);
185-
186-
const nonUtxoCoinStub = {
187-
getFamily: () => CoinFamily.ETH,
188-
getFullName: () => 'Test Ethereum',
184+
const unsupportedCoinStub = {
185+
getFamily: () => CoinFamily.XRP,
186+
getFullName: () => 'Test XRP',
189187
} as unknown as BaseCoin;
190188

191189
before(() => {
@@ -231,8 +229,8 @@ describe('signMultisigTransaction — external signing mode', () => {
231229
getKeyNock.isDone().should.equal(false);
232230
});
233231

234-
it('should fall through to local path for non-UTXO coin in external mode', async () => {
235-
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(nonUtxoCoinStub);
232+
it('should fall through to local path for unsupported coin in external mode', async () => {
233+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(unsupportedCoinStub);
236234

237235
const signNock = nock(keyProviderUrl).post('/sign').reply(200, {});
238236
nock(keyProviderUrl).get(`/key/${userPub}`).query({ source: 'user' }).reply(200, {
@@ -243,7 +241,7 @@ describe('signMultisigTransaction — external signing mode', () => {
243241
});
244242

245243
await agent
246-
.post(`/api/hteth/multisig/sign`)
244+
.post(`/api/hxrp/multisig/sign`)
247245
.set('Authorization', `Bearer ${accessToken}`)
248246
.send({ source: 'user', pub: userPub, txPrebuild: { txHex } });
249247

@@ -261,4 +259,128 @@ describe('signMultisigTransaction — external signing mode', () => {
261259

262260
response.status.should.equal(500);
263261
});
262+
263+
describe('ETH external signing', () => {
264+
const mockOperationHash = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
265+
const mockSignature =
266+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12';
267+
const mockExpireTime = 1735689600;
268+
269+
const ethCoinStub = {
270+
getFamily: () => CoinFamily.ETH,
271+
getFullName: () => 'Test Ethereum',
272+
getDefaultExpireTime: sinon.stub().returns(mockExpireTime),
273+
getOperationSha3ForExecuteAndConfirm: sinon.stub().returns(mockOperationHash),
274+
} as unknown as BaseCoin;
275+
276+
const txPrebuild = {
277+
recipients: [{ amount: '10000', address: '0xe9cbfdf9e02f4ee37ec81683a4be934b4eecc295' }],
278+
nextContractSequenceId: 5,
279+
gasLimit: 200000,
280+
eip1559: { maxPriorityFeePerGas: '599413988', maxFeePerGas: '23556954878' },
281+
isBatch: false,
282+
};
283+
284+
it('should call POST /sign with operationHash and return halfSigned', async () => {
285+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(ethCoinStub);
286+
287+
const signNock = nock(keyProviderUrl)
288+
.post('/sign', {
289+
pub: userPub,
290+
source: 'user',
291+
signablePayload: mockOperationHash,
292+
algorithm: 'ecdsa',
293+
})
294+
.reply(200, { signature: mockSignature });
295+
296+
const getKeyNock = nock(keyProviderUrl).get(`/key/${userPub}`).reply(200, {});
297+
298+
const response = await agent
299+
.post(`/api/hteth/multisig/sign`)
300+
.set('Authorization', `Bearer ${accessToken}`)
301+
.send({ source: 'user', pub: userPub, txPrebuild });
302+
303+
response.status.should.equal(200);
304+
response.body.should.have.property('halfSigned');
305+
response.body.halfSigned.should.have.property('recipients');
306+
response.body.halfSigned.should.have.property('expireTime', mockExpireTime);
307+
response.body.halfSigned.should.have.property(
308+
'contractSequenceId',
309+
txPrebuild.nextContractSequenceId,
310+
);
311+
response.body.halfSigned.should.have.property('operationHash', mockOperationHash);
312+
response.body.halfSigned.should.have.property('signature', mockSignature);
313+
response.body.halfSigned.should.have.property('isBatch', txPrebuild.isBatch);
314+
315+
signNock.done();
316+
317+
/** Validate that the signing was done outside of the app: External Mode */
318+
getKeyNock.isDone().should.equal(false);
319+
});
320+
321+
it('should return error when recipients missing from txPrebuild', async () => {
322+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(ethCoinStub);
323+
const { recipients: __, ...txPrebuildWithoutRecipients } = txPrebuild;
324+
325+
const response = await agent
326+
.post(`/api/hteth/multisig/sign`)
327+
.set('Authorization', `Bearer ${accessToken}`)
328+
.send({ source: 'user', pub: userPub, txPrebuild: txPrebuildWithoutRecipients });
329+
330+
response.status.should.equal(500);
331+
response.body.details.should.match(/recipients, nextContractSequenceId/);
332+
});
333+
334+
it('should successfully sign when nextContractSequenceId is 0', async () => {
335+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(ethCoinStub);
336+
337+
const txPrebuildWithZeroSequenceId = {
338+
...txPrebuild,
339+
nextContractSequenceId: 0,
340+
};
341+
342+
const signNock = nock(keyProviderUrl)
343+
.post('/sign', {
344+
pub: userPub,
345+
source: 'user',
346+
signablePayload: mockOperationHash,
347+
algorithm: 'ecdsa',
348+
})
349+
.reply(200, { signature: mockSignature });
350+
351+
const getKeyNock = nock(keyProviderUrl).get(`/key/${userPub}`).reply(200, {});
352+
353+
const response = await agent
354+
.post(`/api/hteth/multisig/sign`)
355+
.set('Authorization', `Bearer ${accessToken}`)
356+
.send({ source: 'user', pub: userPub, txPrebuild: txPrebuildWithZeroSequenceId });
357+
358+
response.status.should.equal(200);
359+
response.body.should.have.property('halfSigned');
360+
response.body.halfSigned.should.have.property('contractSequenceId', 0);
361+
362+
signNock.done();
363+
getKeyNock.isDone().should.equal(false);
364+
});
365+
366+
it('should return error when keyProvider sign fails', async () => {
367+
coinStub = sinon.stub(coinFactory, 'getCoin').resolves(ethCoinStub);
368+
369+
nock(keyProviderUrl)
370+
.post('/sign', {
371+
pub: userPub,
372+
source: 'user',
373+
signablePayload: mockOperationHash,
374+
algorithm: 'ecdsa',
375+
})
376+
.reply(500, { error: 'KMS unavailable' });
377+
378+
const response = await agent
379+
.post(`/api/hteth/multisig/sign`)
380+
.set('Authorization', `Bearer ${accessToken}`)
381+
.send({ source: 'user', pub: userPub, txPrebuild });
382+
383+
response.status.should.equal(500);
384+
});
385+
});
264386
});

0 commit comments

Comments
 (0)