Skip to content

Commit 38e0a70

Browse files
committed
feat(mbe): create sendMany API signing with ebe
Ticket: 4689
1 parent b2761cf commit 38e0a70

File tree

3 files changed

+193
-21
lines changed

3 files changed

+193
-21
lines changed

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: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import {
1313
} from '@api-ts/typed-express-router';
1414
import { Response } from '@api-ts/response';
1515
import express from 'express';
16-
import { BitGoRequest } from '../../types/request';
16+
import { BitGoRequest, isBitGoRequest } from '../../types/request';
1717
import { MasterExpressConfig } from '../../config';
1818
import { handleGenerateWalletOnPrem } from '../generateWallet';
1919
import { prepareBitGo, responseHandler } from '../../shared/middleware';
20+
import { handleSendMany } from '../handleSendMany';
2021

2122
// Middleware functions
2223
export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) {
@@ -43,6 +44,60 @@ const GenerateWalletRequest = {
4344
isDistributedCustody: t.union([t.undefined, t.boolean]),
4445
};
4546

47+
export const SendManyRequest = t.intersection([
48+
t.type({
49+
pubkey: t.string,
50+
source: t.union([t.literal('user'), t.literal('backup')]),
51+
recipients: t.array(
52+
t.type({
53+
address: t.string,
54+
amount: t.union([t.string, t.number]),
55+
feeLimit: t.union([t.undefined, t.string]),
56+
data: t.union([t.undefined, t.string]),
57+
tokenName: t.union([t.undefined, t.string]),
58+
tokenData: t.union([t.undefined, t.any]),
59+
}),
60+
),
61+
}),
62+
t.partial({
63+
numBlocks: t.number,
64+
feeRate: t.number,
65+
feeMultiplier: t.number,
66+
maxFeeRate: t.number,
67+
minConfirms: t.number,
68+
enforceMinConfirmsForChange: t.boolean,
69+
targetWalletUnspents: t.number,
70+
message: t.string,
71+
minValue: t.union([t.number, t.string]),
72+
maxValue: t.union([t.number, t.string]),
73+
sequenceId: t.string,
74+
lastLedgerSequence: t.number,
75+
ledgerSequenceDelta: t.number,
76+
gasPrice: t.number,
77+
noSplitChange: t.boolean,
78+
unspents: t.array(t.string),
79+
comment: t.string,
80+
otp: t.string,
81+
changeAddress: t.string,
82+
allowExternalChangeAddress: t.boolean,
83+
instant: t.boolean,
84+
memo: t.string,
85+
transferId: t.number,
86+
eip1559: t.any,
87+
gasLimit: t.number,
88+
custodianTransactionId: t.string,
89+
}),
90+
]);
91+
92+
export const SendManyResponse: HttpResponse = {
93+
// TODO: Get type from public types repo / Wallet Platform
94+
200: t.any,
95+
500: t.type({
96+
error: t.string,
97+
details: t.string,
98+
}),
99+
};
100+
46101
// API Specification
47102
export const MasterApiSpec = apiSpec({
48103
'v1.wallet.generate': {
@@ -59,6 +114,21 @@ export const MasterApiSpec = apiSpec({
59114
description: 'Generate a new wallet',
60115
}),
61116
},
117+
'v1.wallet.sendMany': {
118+
post: httpRoute({
119+
method: 'POST',
120+
path: '/{coin}/wallet/{walletId}/sendMany',
121+
request: httpRequest({
122+
params: {
123+
walletId: t.string,
124+
coin: t.string,
125+
},
126+
body: SendManyRequest,
127+
}),
128+
response: SendManyResponse,
129+
description: 'Send many transactions',
130+
}),
131+
},
62132
});
63133

64134
export type MasterApiSpec = typeof MasterApiSpec;
@@ -94,5 +164,13 @@ export function createMasterApiRouter(
94164
}),
95165
]);
96166

167+
router.post('v1.wallet.sendMany', [
168+
responseHandler<MasterExpressConfig>(async (req: express.Request) => {
169+
const typedReq = req as GenericMasterApiSpecRouteRequest;
170+
const result = await handleSendMany(typedReq);
171+
return Response.ok(result);
172+
}),
173+
]);
174+
97175
return router;
98176
}

0 commit comments

Comments
 (0)