Skip to content

Commit d09b586

Browse files
Merge pull request #23 from BitGo/WP-4637_recovery_multisig
recovery multisig for eve and mbe
2 parents 22f63c1 + bbf2bf4 commit d09b586

File tree

10 files changed

+395
-19
lines changed

10 files changed

+395
-19
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"halfSigned": {
3+
"recipients": [
4+
{
5+
"address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20",
6+
"amount": "1000000000000000000"
7+
}
8+
],
9+
"expireTime": 1750182870,
10+
"contractSequenceId": 1,
11+
"operationHash": "0x92d3a28bd75dfa559c60e679b98fddfcb7dcaeb579c25cab3f9442b25fd270e2",
12+
"signature": "0x62c594b62ce2fc9f1d2e82a105668ed53528eb02635b8ad73206fe75ed26b26923450ed54b87980296c362fe03c7bc8e156d1ab38bfe9a682ba585e7d92d88e31b",
13+
"backupKeyNonce": 0
14+
}
15+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"txHex": "f901c380808094223fe2adcc8f28d8a46f72f7f355117d2727554d80b9016439125215000000000000000000000000e7d07af8e3e7472ea8391a3372ab98d04ac4df200000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000006851b0bf000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041917bcebd0b1f43a25b72b161dbd4db539c282af9f3856fc60f701471f0df22e22b5428593f5affc1a88944a5c5255c6c2d0df87a0668864d551a5409ec9f82ca1c000000000000000000000000000000000000000000000000000000000000001ca0c1e0750ac2c3c1cc98997dd1a14f96f1ba65929503bbdbd96ffa00fdeea2e51fa05cca504d869dcf2935c46dc5a733c0c3d8483ce2b452cae2bf89b7417d7f80b9"
3+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"tx": "f9012b808504a817c8008307a12094223fe2adcc8f28d8a46f72f7f355117d2727554d80b9010439125215000000000000000000000000e7d07af8e3e7472ea8391a3372ab98d04ac4df200000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000006851a693000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000808080",
3+
"userKey": "xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK",
4+
"backupKey": "xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e",
5+
"coin": "hteth",
6+
"gasPrice": "20000000000",
7+
"gasLimit": "500000",
8+
"recipients": [
9+
{
10+
"address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20",
11+
"amount": "1000000000000000000"
12+
}
13+
],
14+
"walletContractAddress": "0x223fe2adcc8f28d8a46f72f7f355117d2727554d",
15+
"amount": "1000000000000000000",
16+
"backupKeyNonce": 0,
17+
"recipient": {
18+
"address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20",
19+
"amount": "1000000000000000000"
20+
},
21+
"expireTime": 1750181523,
22+
"contractSequenceId": 1,
23+
"nextContractSequenceId": 1
24+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { SignFinalOptions } from '@bitgo/abstract-eth';
2+
import { MethodNotImplementedError } from 'bitgo';
3+
import { EnclavedApiSpecRouteRequest } from '../../enclavedBitgoExpress/routers/enclavedApiSpec';
4+
import logger from '../../logger';
5+
import { isEthLikeCoin } from '../../shared/coinUtils';
6+
import { retrieveKmsKey } from './utils';
7+
8+
export async function recoveryMultisigTransaction(
9+
req: EnclavedApiSpecRouteRequest<'v1.multisig.recovery', 'post'>,
10+
): Promise<any> {
11+
const { userPub, backupPub, unsignedSweepPrebuildTx, walletContractAddress } = req.body;
12+
13+
//fetch prv and check that pub are valid
14+
const userPrv = await retrieveKmsKey({ pub: userPub, source: 'user', cfg: req.config });
15+
const backupPrv = await retrieveKmsKey({ pub: backupPub, source: 'backup', cfg: req.config });
16+
17+
if (!userPrv || !backupPrv) {
18+
const errorMsg = `Error while recovery wallet, missing prv keys for user or backup on pub keys user=${userPub}, backup=${backupPub}`;
19+
logger.error(errorMsg);
20+
throw new Error(errorMsg);
21+
}
22+
23+
const bitgo = req.bitgo;
24+
const coin = bitgo.coin(req.decoded.coin);
25+
26+
// The signed transaction format depends on the coin type so we do this check as a guard
27+
// If you check the type of coin before and after the "if", you may see "BaseCoin" vs "AbstractEthLikeCoin"
28+
if (coin.isEVM()) {
29+
// Every recovery method on every coin family varies one from another so we need to ensure with a guard.
30+
if (isEthLikeCoin(coin)) {
31+
try {
32+
const halfSignedTx = await coin.signTransaction({
33+
isLastSignature: false,
34+
prv: userPrv,
35+
txPrebuild: { ...unsignedSweepPrebuildTx } as unknown as SignFinalOptions,
36+
walletContractAddress,
37+
});
38+
39+
const { halfSigned } = halfSignedTx as any;
40+
const fullSignedTx = await coin.signTransaction({
41+
isLastSignature: true,
42+
prv: backupPrv,
43+
txPrebuild: {
44+
...halfSignedTx,
45+
txHex: halfSigned.signatures,
46+
halfSigned,
47+
recipients: halfSigned.recipients ?? [],
48+
} as unknown as SignFinalOptions,
49+
walletContractAddress,
50+
signingKeyNonce: halfSigned.signingKeyNonce ?? 0,
51+
backupKeyNonce: halfSigned.backupKeyNonce ?? 0,
52+
recipients: halfSigned.recipients ?? [],
53+
});
54+
55+
return fullSignedTx;
56+
} catch (error) {
57+
logger.error('error while recovering wallet transaction:', error);
58+
throw error;
59+
}
60+
} else {
61+
const errorMsg = 'Unsupported coin type for recovery: ' + req.decoded.coin;
62+
logger.error(errorMsg);
63+
throw new Error(errorMsg);
64+
}
65+
} else {
66+
throw new MethodNotImplementedError('Unsupported coin type for recovery: ' + coin);
67+
}
68+
}

src/api/enclaved/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// TODO: this function is duplicated in multisigTransactioSign.ts but as hardcoded. Replace that code later with this call (to avoid merge conflicts/duplication)
2+
import { KmsClient } from '../../kms/kmsClient';
3+
import { EnclavedConfig } from '../../types';
4+
export async function retrieveKmsKey({
5+
pub,
6+
source,
7+
cfg,
8+
}: {
9+
pub: string;
10+
source: string;
11+
cfg: EnclavedConfig;
12+
}): Promise<string> {
13+
const kms = new KmsClient(cfg);
14+
// Retrieve the private key from KMS
15+
let prv: string;
16+
try {
17+
const res = await kms.getKey({ pub, source });
18+
prv = res.prv;
19+
return prv;
20+
} catch (error: any) {
21+
throw {
22+
status: error.status || 500,
23+
message: error.message || 'Failed to retrieve key from KMS',
24+
};
25+
}
26+
}

src/enclavedBitgoExpress/routers/enclavedApiSpec.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
1-
import * as t from 'io-ts';
21
import {
32
apiSpec,
4-
httpRoute,
3+
Method as HttpMethod,
54
httpRequest,
65
HttpResponse,
7-
Method as HttpMethod,
6+
httpRoute,
87
} from '@api-ts/io-ts-http';
8+
import { Response } from '@api-ts/response';
99
import {
1010
createRouter,
11-
type WrappedRouter,
1211
TypedRequestHandler,
12+
type WrappedRouter,
1313
} from '@api-ts/typed-express-router';
14-
import { Response } from '@api-ts/response';
1514
import express from 'express';
16-
import { BitGoRequest } from '../../types/request';
17-
import { EnclavedConfig } from '../../types';
15+
import * as t from 'io-ts';
1816
import { postIndependentKey } from '../../api/enclaved/postIndependentKey';
17+
import { recoveryMultisigTransaction } from '../../api/enclaved/recoveryMultisigTransaction';
1918
import { signMultisigTransaction } from '../../api/enclaved/signMultisigTransaction';
2019
import { prepareBitGo, responseHandler } from '../../shared/middleware';
20+
import { EnclavedConfig } from '../../types';
21+
import { BitGoRequest } from '../../types/request';
2122

2223
// Request type for /key/independent endpoint
2324
const IndependentKeyRequest = {
@@ -39,7 +40,7 @@ const IndependentKeyResponse: HttpResponse = {
3940
const SignMultisigRequest = {
4041
source: t.string,
4142
pub: t.string,
42-
txPrebuild: t.any, // TransactionPrebuild type from BitGo
43+
txPrebuild: t.any,
4344
};
4445

4546
// Response type for /multisig/sign endpoint
@@ -52,6 +53,32 @@ const SignMultisigResponse: HttpResponse = {
5253
}),
5354
};
5455

56+
// Request type for /multisig/recovery endpoint
57+
const RecoveryMultisigRequest = {
58+
userPub: t.string,
59+
backupPub: t.string,
60+
apiKey: t.string,
61+
// TODO: best typing for this, they come from sdk TS types
62+
unsignedSweepPrebuildTx: t.any,
63+
coinSpecificParams: t.union([
64+
t.undefined,
65+
t.partial({
66+
bitgoPub: t.union([t.undefined, t.string]),
67+
ignoreAddressTypes: t.union([t.undefined, t.array(t.string)]),
68+
}),
69+
]),
70+
};
71+
72+
// Response type for /multisig/recovery endpoint
73+
const RecoveryMultisigResponse: HttpResponse = {
74+
// TODO: Define proper response type for recovery multisig transaction
75+
200: t.any, // the full signed tx
76+
500: t.type({
77+
error: t.string,
78+
details: t.string,
79+
}),
80+
};
81+
5582
// API Specification
5683
export const EnclavedAPiSpec = apiSpec({
5784
'v1.multisig.sign': {
@@ -68,6 +95,20 @@ export const EnclavedAPiSpec = apiSpec({
6895
description: 'Sign a multisig transaction',
6996
}),
7097
},
98+
'v1.multisig.recovery': {
99+
post: httpRoute({
100+
method: 'POST',
101+
path: '/{coin}/multisig/recovery',
102+
request: httpRequest({
103+
params: {
104+
coin: t.string,
105+
},
106+
body: RecoveryMultisigRequest,
107+
}),
108+
response: RecoveryMultisigResponse,
109+
description: 'Recover a multisig transaction',
110+
}),
111+
},
71112
'v1.key.independent': {
72113
post: httpRoute({
73114
method: 'POST',
@@ -121,5 +162,13 @@ export function createKeyGenRouter(config: EnclavedConfig): WrappedRouter<typeof
121162
}),
122163
]);
123164

165+
router.post('v1.multisig.recovery', [
166+
responseHandler<EnclavedConfig>(async (req) => {
167+
const typedReq = req as EnclavedApiSpecRouteRequest<'v1.multisig.recovery', 'post'>;
168+
const result = await recoveryMultisigTransaction(typedReq);
169+
return Response.ok(result);
170+
}),
171+
]);
172+
124173
return router;
125174
}

src/masterBitgoExpress/enclavedExpressClient.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import superagent from 'superagent';
2-
import https from 'https';
3-
import debug from 'debug';
4-
import { MasterExpressConfig } from '../types';
5-
import { TlsMode } from '../types';
1+
import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth';
62
import { SignedTransaction, TransactionPrebuild } from '@bitgo/sdk-core';
3+
import debug from 'debug';
4+
import https from 'https';
5+
import superagent from 'superagent';
6+
import { MasterExpressConfig, TlsMode } from '../types';
77

88
const debugLogger = debug('bitgo:express:enclavedExpressClient');
99

@@ -29,6 +29,18 @@ interface SignMultisigOptions {
2929
pub: string;
3030
}
3131

32+
interface RecoveryMultisigOptions {
33+
userPub: string;
34+
backupPub: string;
35+
unsignedSweepPrebuildTx: RecoveryInfo | OfflineVaultTxInfo | UnsignedSweepTxMPCv2;
36+
apiKey: string;
37+
walletContractAddress: string;
38+
coinSpecificParams?: {
39+
bitgoPub?: string;
40+
ignoreAddressTypes?: string[];
41+
};
42+
}
43+
3244
export class EnclavedExpressClient {
3345
private readonly baseUrl: string;
3446
private readonly enclavedExpressCert: string;
@@ -134,6 +146,27 @@ export class EnclavedExpressClient {
134146
throw err;
135147
}
136148
}
149+
150+
/**
151+
* Recover a multisig transaction
152+
*/
153+
async recoveryMultisig(params: RecoveryMultisigOptions): Promise<SignedTransaction> {
154+
if (!this.coin) {
155+
throw new Error('Coin must be specified to recover a multisig');
156+
}
157+
158+
try {
159+
const res = await this.configureRequest(
160+
superagent.post(`${this.baseUrl}/api/${this.coin}/multisig/recovery`).type('json'),
161+
).send(params);
162+
163+
return res.body;
164+
} catch (error) {
165+
const err = error as Error;
166+
debugLogger('Failed to recover multisig: %s', err.message);
167+
throw err;
168+
}
169+
}
137170
}
138171

139172
/**
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { MethodNotImplementedError } from 'bitgo';
2+
import { isEthLikeCoin } from '../shared/coinUtils';
3+
import { MasterApiSpecRouteRequest } from './routers/masterApiSpec';
4+
5+
export async function handleRecoveryWalletOnPrem(
6+
req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>,
7+
) {
8+
const bitgo = req.bitgo;
9+
const coin = req.decoded.coin;
10+
const enclavedExpressClient = req.enclavedExpressClient;
11+
12+
const {
13+
userPub,
14+
backupPub,
15+
walletContractAddress,
16+
recoveryDestinationAddress,
17+
coinSpecificParams,
18+
apiKey,
19+
} = req.decoded;
20+
21+
//construct a common payload for the recovery that it's repeated in any kind of recovery
22+
const commonRecoveryParams = {
23+
userKey: userPub,
24+
backupKey: backupPub,
25+
walletContractAddress,
26+
recoveryDestination: recoveryDestinationAddress,
27+
apiKey,
28+
};
29+
30+
const sdkCoin = bitgo.coin(coin);
31+
32+
if (isEthLikeCoin(sdkCoin)) {
33+
try {
34+
const unsignedSweepPrebuildTx = await sdkCoin.recover({
35+
...commonRecoveryParams,
36+
});
37+
const fullSignedRecoveryTx = await enclavedExpressClient.recoveryMultisig({
38+
userPub,
39+
backupPub,
40+
apiKey,
41+
unsignedSweepPrebuildTx,
42+
coinSpecificParams,
43+
walletContractAddress,
44+
});
45+
46+
return fullSignedRecoveryTx;
47+
} catch (err) {
48+
throw err;
49+
}
50+
} else {
51+
throw new MethodNotImplementedError('Recovery wallet is not supported for this coin: ' + coin);
52+
}
53+
}

0 commit comments

Comments
 (0)