Skip to content

Commit f0b3c5e

Browse files
committed
feat(mbe): create sendMany API signing with ebe
Ticket: 4689
1 parent bf8f0d4 commit f0b3c5e

3 files changed

Lines changed: 194 additions & 20 deletions

File tree

src/masterBitgoExpress/enclavedExpressClient.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import https from 'https';
33
import debug from 'debug';
44
import { MasterExpressConfig } from '../types';
55
import { TlsMode } from '../types';
6+
import { SignMultisigOptions } from '../types/masterApiTypes';
7+
import { SignedTransaction } from '@bitgo/sdk-core';
68

79
const debugLogger = debug('bitgo:express:enclavedExpressClient');
810

@@ -62,15 +64,20 @@ export class EnclavedExpressClient {
6264
});
6365
}
6466

67+
/**
68+
* Configure the request to use the appropriate TLS mode
69+
*/
70+
private configureRequest(request: superagent.SuperAgentRequest): superagent.SuperAgentRequest {
71+
if (this.tlsMode === TlsMode.MTLS) {
72+
return request.agent(this.createHttpsAgent());
73+
}
74+
return request;
75+
}
76+
6577
async ping(): Promise<void> {
6678
try {
6779
debugLogger('Pinging enclaved express at %s', this.baseUrl);
68-
if (this.tlsMode === TlsMode.MTLS) {
69-
await superagent.get(`${this.baseUrl}/ping`).agent(this.createHttpsAgent()).send();
70-
} else {
71-
// When TLS is disabled, use plain HTTP without any TLS configuration
72-
await superagent.get(`${this.baseUrl}/ping`).send();
73-
}
80+
await this.configureRequest(superagent.get(`${this.baseUrl}/ping`)).send();
7481
} catch (error) {
7582
const err = error as Error;
7683
debugLogger('Failed to ping enclaved express: %s', err.message);
@@ -90,20 +97,9 @@ export class EnclavedExpressClient {
9097

9198
try {
9299
debugLogger('Creating independent keychain for coin: %s', this.coin);
93-
let response;
94-
if (this.tlsMode === TlsMode.MTLS) {
95-
response = await superagent
96-
.post(`${this.baseUrl}/api/${this.coin}/key/independent`)
97-
.agent(this.createHttpsAgent())
98-
.type('json')
99-
.send(params);
100-
} else {
101-
// When TLS is disabled, use plain HTTP without any TLS configuration
102-
response = await superagent
103-
.post(`${this.baseUrl}/api/${this.coin}/key/independent`)
104-
.type('json')
105-
.send(params);
106-
}
100+
const response = await this.configureRequest(
101+
superagent.post(`${this.baseUrl}/api/${this.coin}/key/independent`).type('json'),
102+
).send(params);
107103

108104
return response.body;
109105
} catch (error) {
@@ -112,6 +108,27 @@ export class EnclavedExpressClient {
112108
throw err;
113109
}
114110
}
111+
112+
/**
113+
* Sign a multisig transaction
114+
*/
115+
async signMultisig(params: SignMultisigOptions): Promise<SignedTransaction> {
116+
if (!this.coin) {
117+
throw new Error('Coin must be specified to sign a multisig');
118+
}
119+
120+
try {
121+
const res = await this.configureRequest(
122+
superagent.post(`${this.baseUrl}/api/${this.coin}/signMultisig`).type('json'),
123+
).send(params);
124+
125+
return res.body;
126+
} catch (error) {
127+
const err = error as Error;
128+
debugLogger('Failed to sign multisig: %s', err.message);
129+
throw err;
130+
}
131+
}
115132
}
116133

117134
/**
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { RequestTracer, PrebuildTransactionOptions, Memo } from '@bitgo/sdk-core';
2+
import { BitGoRequest } from '../types/request';
3+
import { createEnclavedExpressClient } from './enclavedExpressClient';
4+
import logger from '../logger';
5+
import { SendManyRequest } from './routers/masterApiSpec';
6+
import { TypeOf } from 'io-ts';
7+
8+
export async function handleSendMany(req: BitGoRequest) {
9+
const enclavedExpressClient = createEnclavedExpressClient(req.config, req.params.coin);
10+
if (!enclavedExpressClient) {
11+
throw new Error('Please configure enclaved express configs to sign the transactions.');
12+
}
13+
const reqId = new RequestTracer();
14+
const bitgo = req.bitgo;
15+
const baseCoin = bitgo.coin(req.params.coin);
16+
17+
const params = req.body as TypeOf<typeof SendManyRequest>;
18+
const walletId = req.params.walletId;
19+
const wallet = await baseCoin.wallets().get({ id: walletId, reqId });
20+
if (!wallet) {
21+
throw new Error(`Wallet ${walletId} not found`);
22+
}
23+
24+
if (wallet.type() !== 'cold' || wallet.subType() !== 'onPrem') {
25+
throw new Error('Wallet is not an on-prem wallet');
26+
}
27+
28+
// Get the signing keychains
29+
const signingKeychains = await baseCoin.keychains().getKeysForSigning({
30+
wallet,
31+
reqId,
32+
});
33+
34+
// Find the user keychain for signing
35+
const signingKeychain = signingKeychains.find((k) => k.source === params.source);
36+
if (!signingKeychain) {
37+
throw new Error(`Signing keychain for ${params.source} not found`);
38+
}
39+
40+
try {
41+
const prebuildParams: PrebuildTransactionOptions = {
42+
...params,
43+
// Convert memo string to Memo object if present
44+
memo: params.memo ? ({ type: 'text', value: params.memo } as Memo) : undefined,
45+
};
46+
47+
// First build the transaction
48+
const txPrebuild = await wallet.prebuildTransaction({
49+
...prebuildParams,
50+
reqId,
51+
});
52+
53+
// Then sign it using the enclaved express client
54+
const signedTx = await enclavedExpressClient.signMultisig({
55+
txPrebuild,
56+
source: params.source,
57+
pub: signingKeychain.pub,
58+
});
59+
60+
// Get extra prebuild parameters
61+
const extraParams = await baseCoin.getExtraPrebuildParams({
62+
...params,
63+
wallet,
64+
});
65+
66+
// Combine the signed transaction with extra parameters
67+
const finalTxParams = { ...signedTx, ...extraParams };
68+
69+
// Submit the half signed transaction
70+
const result = (await wallet.submitTransaction(finalTxParams, reqId)) as any;
71+
return result;
72+
} catch (error) {
73+
const err = error as Error;
74+
logger.error('Failed to send many: %s', err.message);
75+
throw err;
76+
}
77+
}

src/masterBitgoExpress/routers/masterApiSpec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { BitGoRequest } from '../../types/request';
1818
import { MasterExpressConfig } from '../../config';
1919
import { handleGenerateWalletOnPrem } from '../generateWallet';
2020
import { withResponseHandler } from '../../shared/responseHandler';
21+
import { handleSendMany } from '../handleSendMany';
2122

2223
// Middleware functions
2324
export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) {
@@ -78,6 +79,60 @@ const GenerateWalletRequest = {
7879
isDistributedCustody: t.union([t.undefined, t.boolean]),
7980
};
8081

82+
export const SendManyRequest = t.intersection([
83+
t.type({
84+
pubkey: t.string,
85+
source: t.union([t.literal('user'), t.literal('backup')]),
86+
recipients: t.array(
87+
t.type({
88+
address: t.string,
89+
amount: t.union([t.string, t.number]),
90+
feeLimit: t.union([t.undefined, t.string]),
91+
data: t.union([t.undefined, t.string]),
92+
tokenName: t.union([t.undefined, t.string]),
93+
tokenData: t.union([t.undefined, t.any]),
94+
}),
95+
),
96+
}),
97+
t.partial({
98+
numBlocks: t.number,
99+
feeRate: t.number,
100+
feeMultiplier: t.number,
101+
maxFeeRate: t.number,
102+
minConfirms: t.number,
103+
enforceMinConfirmsForChange: t.boolean,
104+
targetWalletUnspents: t.number,
105+
message: t.string,
106+
minValue: t.union([t.number, t.string]),
107+
maxValue: t.union([t.number, t.string]),
108+
sequenceId: t.string,
109+
lastLedgerSequence: t.number,
110+
ledgerSequenceDelta: t.number,
111+
gasPrice: t.number,
112+
noSplitChange: t.boolean,
113+
unspents: t.array(t.string),
114+
comment: t.string,
115+
otp: t.string,
116+
changeAddress: t.string,
117+
allowExternalChangeAddress: t.boolean,
118+
instant: t.boolean,
119+
memo: t.string,
120+
transferId: t.number,
121+
eip1559: t.any,
122+
gasLimit: t.number,
123+
custodianTransactionId: t.string,
124+
}),
125+
]);
126+
127+
export const SendManyResponse: HttpResponse = {
128+
// TODO: Get type from public types repo / Wallet Platform
129+
200: t.any,
130+
500: t.type({
131+
error: t.string,
132+
details: t.string,
133+
}),
134+
};
135+
81136
// API Specification
82137
export const MasterApiSpec = apiSpec({
83138
'v1.wallet.generate': {
@@ -94,6 +149,21 @@ export const MasterApiSpec = apiSpec({
94149
description: 'Generate a new wallet',
95150
}),
96151
},
152+
'v1.wallet.sendMany': {
153+
post: httpRoute({
154+
method: 'POST',
155+
path: '/{coin}/wallet/{walletId}/sendMany',
156+
request: httpRequest({
157+
params: {
158+
walletId: t.string,
159+
coin: t.string,
160+
},
161+
body: SendManyRequest,
162+
}),
163+
response: SendManyResponse,
164+
description: 'Send many transactions',
165+
}),
166+
},
97167
});
98168

99169
export type MasterApiSpec = typeof MasterApiSpec;
@@ -128,5 +198,15 @@ export function createMasterApiRouter(
128198
}),
129199
]);
130200

201+
router.post('v1.wallet.sendMany', [
202+
withResponseHandler(async (req: BitGoRequest | Request) => {
203+
if (!isBitGoRequest(req)) {
204+
throw new Error('Invalid request type');
205+
}
206+
const result = await handleSendMany(req);
207+
return Response.ok(result);
208+
}),
209+
]);
210+
131211
return router;
132212
}

0 commit comments

Comments
 (0)