Skip to content

Commit bfa2aae

Browse files
authored
feat(express): migrated coinSign as type route
2 parents 6bf1753 + e53a947 commit bfa2aae

File tree

6 files changed

+1084
-6
lines changed

6 files changed

+1084
-6
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ export async function handleV2SignTSSWalletTx(req: ExpressApiRouteRequest<'expre
509509
/**
510510
* This route is used to sign while external express signer is enabled
511511
*/
512-
export async function handleV2Sign(req: express.Request) {
512+
export async function handleV2Sign(req: ExpressApiRouteRequest<'express.v2.coin.sign', 'post'>) {
513513
const walletId = req.body.txPrebuild?.walletId;
514514

515515
if (!walletId) {
@@ -526,7 +526,7 @@ export async function handleV2Sign(req: express.Request) {
526526
const encryptedPrivKey = await getEncryptedPrivKey(signerFileSystemPath, walletId);
527527
const bitgo = req.bitgo;
528528
let privKey = decryptPrivKey(bitgo, encryptedPrivKey, walletPw);
529-
const coin = bitgo.coin(req.params.coin);
529+
const coin = bitgo.coin(req.decoded.coin);
530530
if (req.body.derivationSeed) {
531531
privKey = coin.deriveKeyWithSeed({ key: privKey, seed: req.body.derivationSeed }).key;
532532
}
@@ -1731,7 +1731,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
17311731
}
17321732

17331733
export function setupSigningRoutes(app: express.Application, config: Config): void {
1734-
app.post('/api/v2/:coin/sign', parseBody, prepareBitGo(config), promiseWrapper(handleV2Sign));
1734+
const router = createExpressRouter();
1735+
app.use(router);
1736+
1737+
router.post('express.v2.coin.sign', [prepareBitGo(config), typedPromiseWrapper(handleV2Sign)]);
17351738
app.post(
17361739
'/api/v2/:coin/tssshare/:sharetype',
17371740
parseBody,

modules/express/src/typedRoutes/api/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { PostShareWallet } from './v2/shareWallet';
3535
import { PutExpressWalletUpdate } from './v2/expressWalletUpdate';
3636
import { PostFanoutUnspents } from './v2/fanoutUnspents';
3737
import { PostConsolidateUnspents } from './v2/consolidateunspents';
38+
import { PostCoinSign } from './v2/coinSign';
3839

3940
// Too large types can cause the following error
4041
//
@@ -190,18 +191,33 @@ export const ExpressOfcSignPayloadApiSpec = apiSpec({
190191
'express.ofc.signPayload': {
191192
post: PostOfcSignPayload,
192193
},
194+
});
195+
196+
export const ExpressWalletRecoverTokenApiSpec = apiSpec({
193197
'express.v2.wallet.recovertoken': {
194198
post: PostWalletRecoverToken,
195199
},
200+
});
201+
202+
export const ExpressCoinSigningApiSpec = apiSpec({
196203
'express.v2.coin.signtx': {
197204
post: PostCoinSignTx,
198205
},
206+
'express.v2.coin.sign': {
207+
post: PostCoinSign,
208+
},
209+
});
210+
211+
export const ExpressWalletSigningApiSpec = apiSpec({
199212
'express.v2.wallet.signtx': {
200213
post: PostWalletSignTx,
201214
},
202215
'express.v2.wallet.signtxtss': {
203216
post: PostWalletTxSignTSS,
204217
},
218+
});
219+
220+
export const ExpressWalletManagementApiSpec = apiSpec({
205221
'express.v2.wallet.share': {
206222
post: PostShareWallet,
207223
},
@@ -236,7 +252,11 @@ export type ExpressApi = typeof ExpressPingApiSpec &
236252
typeof ExpressLightningGetStateApiSpec &
237253
typeof ExpressLightningInitWalletApiSpec &
238254
typeof ExpressLightningUnlockWalletApiSpec &
239-
typeof ExpressOfcSignPayloadApiSpec;
255+
typeof ExpressOfcSignPayloadApiSpec &
256+
typeof ExpressWalletRecoverTokenApiSpec &
257+
typeof ExpressCoinSigningApiSpec &
258+
typeof ExpressWalletSigningApiSpec &
259+
typeof ExpressWalletManagementApiSpec;
240260

241261
export const ExpressApi: ExpressApi = {
242262
...ExpressPingApiSpec,
@@ -263,6 +283,10 @@ export const ExpressApi: ExpressApi = {
263283
...ExpressLightningInitWalletApiSpec,
264284
...ExpressLightningUnlockWalletApiSpec,
265285
...ExpressOfcSignPayloadApiSpec,
286+
...ExpressWalletRecoverTokenApiSpec,
287+
...ExpressCoinSigningApiSpec,
288+
...ExpressWalletSigningApiSpec,
289+
...ExpressWalletManagementApiSpec,
266290
};
267291

268292
type ExtractDecoded<T> = T extends t.Type<any, infer O, any> ? O : never;
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import * as t from 'io-ts';
2+
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
3+
import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types';
4+
import { BitgoExpressError } from '../../schemas/error';
5+
6+
/**
7+
* Request parameters for signing a transaction (external signer mode)
8+
*/
9+
export const CoinSignParams = {
10+
/** The coin type */
11+
coin: t.string,
12+
} as const;
13+
14+
/**
15+
* Transaction prebuild information for external signing
16+
* Requires walletId to retrieve encrypted private key from filesystem
17+
*/
18+
export const TransactionPrebuildForExternalSigning = t.intersection([
19+
t.type({
20+
/** Wallet ID - required for retrieving encrypted private key */
21+
walletId: t.string,
22+
}),
23+
t.partial({
24+
/** Transaction in hex format */
25+
txHex: t.string,
26+
/** Transaction in base64 format (for some coins) */
27+
txBase64: t.string,
28+
/** Transaction in JSON format (for some coins) */
29+
txInfo: t.any,
30+
/** Next contract sequence ID (for ETH) */
31+
nextContractSequenceId: t.number,
32+
/** Whether this is a batch transaction (for ETH) */
33+
isBatch: t.boolean,
34+
/** EIP1559 transaction parameters (for ETH) */
35+
eip1559: t.any,
36+
/** Hop transaction data (for ETH) */
37+
hopTransaction: t.any,
38+
/** Backup key nonce (for ETH) */
39+
backupKeyNonce: t.any,
40+
/** Recipients of the transaction */
41+
recipients: t.any,
42+
}),
43+
]);
44+
45+
/**
46+
* Request body for signing a transaction in external signer mode
47+
*
48+
* This route is used when BitGo Express is configured with external signing.
49+
* The private key is retrieved from the filesystem and decrypted using
50+
* a wallet passphrase stored in the environment variable WALLET_{walletId}_PASSPHRASE.
51+
*/
52+
export const CoinSignBody = {
53+
/** Transaction prebuild data - must contain walletId */
54+
txPrebuild: TransactionPrebuildForExternalSigning,
55+
/**
56+
* Derivation seed for deriving a child key from the main private key.
57+
* If provided, the key will be derived using coin.deriveKeyWithSeed()
58+
*/
59+
derivationSeed: optional(t.string),
60+
/** Whether this is the last signature in a multi-sig tx */
61+
isLastSignature: optional(t.boolean),
62+
/** Gas limit for ETH transactions */
63+
gasLimit: optional(t.union([t.string, t.number])),
64+
/** Gas price for ETH transactions */
65+
gasPrice: optional(t.union([t.string, t.number])),
66+
/** Transaction expiration time */
67+
expireTime: optional(t.number),
68+
/** Sequence ID for transactions */
69+
sequenceId: optional(t.number),
70+
/** Public keys for multi-signature transactions */
71+
pubKeys: optional(t.array(t.string)),
72+
/** For EVM cross-chain recovery */
73+
isEvmBasedCrossChainRecovery: optional(t.boolean),
74+
/** Recipients of the transaction */
75+
recipients: optional(t.any),
76+
/** Custodian transaction ID */
77+
custodianTransactionId: optional(t.string),
78+
/** Signing step for MuSig2 */
79+
signingStep: optional(t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')])),
80+
/** Allow non-segwit signing without previous transaction */
81+
allowNonSegwitSigningWithoutPrevTx: optional(t.boolean),
82+
} as const;
83+
84+
/**
85+
* Response for a fully signed transaction
86+
*/
87+
export const FullySignedTransactionResponse = t.type({
88+
/** Transaction in hex format */
89+
txHex: t.string,
90+
});
91+
92+
/**
93+
* Response for a half-signed account transaction
94+
*/
95+
export const HalfSignedAccountTransactionResponse = t.partial({
96+
halfSigned: t.partial({
97+
txHex: t.string,
98+
payload: t.string,
99+
txBase64: t.string,
100+
}),
101+
});
102+
103+
/**
104+
* Response for a half-signed UTXO transaction
105+
*/
106+
export const HalfSignedUtxoTransactionResponse = t.type({
107+
txHex: t.string,
108+
});
109+
110+
/**
111+
* Response for a transaction request
112+
*/
113+
export const SignedTransactionRequestResponse = t.type({
114+
txRequestId: t.string,
115+
});
116+
117+
/**
118+
* Response for signing a transaction in external signer mode
119+
*
120+
* The response format matches coin.signTransaction() and varies based on:
121+
* - Whether the transaction is fully or half-signed
122+
* - The coin type (UTXO vs Account-based)
123+
* - Whether TSS is used (returns TxRequestResponse)
124+
*/
125+
export const CoinSignResponse = {
126+
/** Successfully signed transaction */
127+
200: t.union([
128+
FullySignedTransactionResponse,
129+
HalfSignedAccountTransactionResponse,
130+
HalfSignedUtxoTransactionResponse,
131+
SignedTransactionRequestResponse,
132+
TxRequestResponse,
133+
]),
134+
/** Error response - validation or signing errors */
135+
400: BitgoExpressError,
136+
};
137+
138+
/**
139+
* Sign a transaction using external signer mode
140+
*
141+
* This endpoint is used when BitGo Express is configured with external signing
142+
* (signerFileSystemPath config is set). It:
143+
*
144+
* 1. Retrieves the encrypted private key from the filesystem using walletId
145+
* 2. Decrypts it using the wallet passphrase from environment (WALLET_{walletId}_PASSPHRASE)
146+
* 3. Optionally derives a child key if derivationSeed is provided
147+
* 4. Signs the transaction using the private key
148+
*
149+
* **Configuration Requirements:**
150+
* - `signerFileSystemPath`: Path to JSON file containing encrypted private keys
151+
* - Environment variable: `WALLET_{walletId}_PASSPHRASE` for each wallet
152+
*
153+
* **Request Body:**
154+
* - `txPrebuild`: Transaction prebuild data (must include walletId)
155+
* - `derivationSeed`: Optional seed for deriving a child key
156+
* - Other fields are passed to coin.signTransaction()
157+
*
158+
* **Response:**
159+
* - Fully signed transaction (if all signatures collected)
160+
* - Half-signed transaction (if more signatures needed)
161+
* - Transaction request ID (for TSS wallets)
162+
*
163+
* @tag express
164+
* @operationId express.v2.coin.sign
165+
*/
166+
export const PostCoinSign = httpRoute({
167+
path: '/api/v2/{coin}/sign',
168+
method: 'POST',
169+
request: httpRequest({
170+
params: CoinSignParams,
171+
body: CoinSignBody,
172+
}),
173+
response: CoinSignResponse,
174+
});

modules/express/test/lib/testutil.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ export function unlockToken(agent, accessToken, seconds) {
1515
});
1616
}
1717

18-
export function setupAgent(): request.SuperAgentTest {
18+
export function setupAgent(config?: any): request.SuperAgentTest {
1919
const args: any = {
2020
debug: false,
2121
env: 'test',
2222
logfile: '/dev/null',
23+
...config,
2324
};
2425

2526
const app = expressApp(args);

modules/express/test/unit/clientRoutes/externalSign.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,16 @@ describe('External signer', () => {
119119
params: {
120120
coin: 'tbtc',
121121
},
122+
decoded: {
123+
coin: 'tbtc',
124+
txPrebuild: {
125+
walletId: walletId,
126+
},
127+
},
122128
config: {
123129
signerFileSystemPath: 'signerFileSystemPath',
124130
},
125-
} as unknown as express.Request;
131+
} as any;
126132

127133
await handleV2Sign(req);
128134

0 commit comments

Comments
 (0)