Skip to content

Commit 6bcd18e

Browse files
committed
feat(mbe): create sendMany API signing with ebe
Ticket: 4689
1 parent 6de52b8 commit 6bcd18e

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
@@ -8,6 +8,7 @@ import { BitGoRequest, isBitGoRequest } from '../../types/request';
88
import { MasterExpressConfig } from '../../config';
99
import { handleGenerateWalletOnPrem } from '../generateWallet';
1010
import { withResponseHandler } from '../../shared/responseHandler';
11+
import { handleSendMany } from '../handleSendMany';
1112

1213
// Middleware functions
1314
export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) {
@@ -68,6 +69,60 @@ const GenerateWalletRequest = {
6869
isDistributedCustody: t.union([t.undefined, t.boolean]),
6970
};
7071

72+
export const SendManyRequest = t.intersection([
73+
t.type({
74+
pubkey: t.string,
75+
source: t.union([t.literal('user'), t.literal('backup')]),
76+
recipients: t.array(
77+
t.type({
78+
address: t.string,
79+
amount: t.union([t.string, t.number]),
80+
feeLimit: t.union([t.undefined, t.string]),
81+
data: t.union([t.undefined, t.string]),
82+
tokenName: t.union([t.undefined, t.string]),
83+
tokenData: t.union([t.undefined, t.any]),
84+
}),
85+
),
86+
}),
87+
t.partial({
88+
numBlocks: t.number,
89+
feeRate: t.number,
90+
feeMultiplier: t.number,
91+
maxFeeRate: t.number,
92+
minConfirms: t.number,
93+
enforceMinConfirmsForChange: t.boolean,
94+
targetWalletUnspents: t.number,
95+
message: t.string,
96+
minValue: t.union([t.number, t.string]),
97+
maxValue: t.union([t.number, t.string]),
98+
sequenceId: t.string,
99+
lastLedgerSequence: t.number,
100+
ledgerSequenceDelta: t.number,
101+
gasPrice: t.number,
102+
noSplitChange: t.boolean,
103+
unspents: t.array(t.string),
104+
comment: t.string,
105+
otp: t.string,
106+
changeAddress: t.string,
107+
allowExternalChangeAddress: t.boolean,
108+
instant: t.boolean,
109+
memo: t.string,
110+
transferId: t.number,
111+
eip1559: t.any,
112+
gasLimit: t.number,
113+
custodianTransactionId: t.string,
114+
}),
115+
]);
116+
117+
export const SendManyResponse: HttpResponse = {
118+
// TODO: Get type from public types repo / Wallet Platform
119+
200: t.any,
120+
500: t.type({
121+
error: t.string,
122+
details: t.string,
123+
}),
124+
};
125+
71126
// API Specification
72127
export const MasterApiSpec = apiSpec({
73128
'v1.wallet.generate': {
@@ -84,6 +139,21 @@ export const MasterApiSpec = apiSpec({
84139
description: 'Generate a new wallet',
85140
}),
86141
},
142+
'v1.wallet.sendMany': {
143+
post: httpRoute({
144+
method: 'POST',
145+
path: '/{coin}/wallet/{walletId}/sendMany',
146+
request: httpRequest({
147+
params: {
148+
walletId: t.string,
149+
coin: t.string,
150+
},
151+
body: SendManyRequest,
152+
}),
153+
response: SendManyResponse,
154+
description: 'Send many transactions',
155+
}),
156+
},
87157
});
88158

89159
// Create router with handlers
@@ -107,5 +177,15 @@ export function createMasterApiRouter(
107177
}),
108178
]);
109179

180+
router.post('v1.wallet.sendMany', [
181+
withResponseHandler(async (req: BitGoRequest | Request) => {
182+
if (!isBitGoRequest(req)) {
183+
throw new Error('Invalid request type');
184+
}
185+
const result = await handleSendMany(req);
186+
return Response.ok(result);
187+
}),
188+
]);
189+
110190
return router;
111191
}

0 commit comments

Comments
 (0)