Skip to content

Commit cabb3a0

Browse files
committed
feat: add ETH support for multisign in external mode
This commit adds support for signing multisig transactions for ETH in external mode. It includes validation to ensure that the transaction prebuild contains the necessary fields for ETH transactions, such as recipients and nextContractSequenceId. The tests have been updated to reflect these changes and to ensure that the new functionality works as expected - tested e2e. Re Recovery Flow: it requires a few methods that are not exposed by COINS team, so that will be a follow-up work. Ticket: WCN-544
1 parent 46c5637 commit cabb3a0

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)