Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ export async function handleV2SignTSSWalletTx(req: ExpressApiRouteRequest<'expre
/**
* This route is used to sign while external express signer is enabled
*/
export async function handleV2Sign(req: express.Request) {
export async function handleV2Sign(req: ExpressApiRouteRequest<'express.v2.coin.sign', 'post'>) {
const walletId = req.body.txPrebuild?.walletId;

if (!walletId) {
Expand All @@ -526,7 +526,7 @@ export async function handleV2Sign(req: express.Request) {
const encryptedPrivKey = await getEncryptedPrivKey(signerFileSystemPath, walletId);
const bitgo = req.bitgo;
let privKey = decryptPrivKey(bitgo, encryptedPrivKey, walletPw);
const coin = bitgo.coin(req.params.coin);
const coin = bitgo.coin(req.decoded.coin);
if (req.body.derivationSeed) {
privKey = coin.deriveKeyWithSeed({ key: privKey, seed: req.body.derivationSeed }).key;
}
Expand Down Expand Up @@ -1731,7 +1731,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
}

export function setupSigningRoutes(app: express.Application, config: Config): void {
app.post('/api/v2/:coin/sign', parseBody, prepareBitGo(config), promiseWrapper(handleV2Sign));
const router = createExpressRouter();
app.use(router);

router.post('express.v2.coin.sign', [prepareBitGo(config), typedPromiseWrapper(handleV2Sign)]);
app.post(
'/api/v2/:coin/tssshare/:sharetype',
parseBody,
Expand Down
26 changes: 25 additions & 1 deletion modules/express/src/typedRoutes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { PostShareWallet } from './v2/shareWallet';
import { PutExpressWalletUpdate } from './v2/expressWalletUpdate';
import { PostFanoutUnspents } from './v2/fanoutUnspents';
import { PostConsolidateUnspents } from './v2/consolidateunspents';
import { PostCoinSign } from './v2/coinSign';

// Too large types can cause the following error
//
Expand Down Expand Up @@ -190,18 +191,33 @@ export const ExpressOfcSignPayloadApiSpec = apiSpec({
'express.ofc.signPayload': {
post: PostOfcSignPayload,
},
});

export const ExpressWalletRecoverTokenApiSpec = apiSpec({
'express.v2.wallet.recovertoken': {
post: PostWalletRecoverToken,
},
});

export const ExpressCoinSigningApiSpec = apiSpec({
'express.v2.coin.signtx': {
post: PostCoinSignTx,
},
'express.v2.coin.sign': {
post: PostCoinSign,
},
});

export const ExpressWalletSigningApiSpec = apiSpec({
'express.v2.wallet.signtx': {
post: PostWalletSignTx,
},
'express.v2.wallet.signtxtss': {
post: PostWalletTxSignTSS,
},
});

export const ExpressWalletManagementApiSpec = apiSpec({
'express.v2.wallet.share': {
post: PostShareWallet,
},
Expand Down Expand Up @@ -236,7 +252,11 @@ export type ExpressApi = typeof ExpressPingApiSpec &
typeof ExpressLightningGetStateApiSpec &
typeof ExpressLightningInitWalletApiSpec &
typeof ExpressLightningUnlockWalletApiSpec &
typeof ExpressOfcSignPayloadApiSpec;
typeof ExpressOfcSignPayloadApiSpec &
typeof ExpressWalletRecoverTokenApiSpec &
typeof ExpressCoinSigningApiSpec &
typeof ExpressWalletSigningApiSpec &
typeof ExpressWalletManagementApiSpec;

export const ExpressApi: ExpressApi = {
...ExpressPingApiSpec,
Expand All @@ -263,6 +283,10 @@ export const ExpressApi: ExpressApi = {
...ExpressLightningInitWalletApiSpec,
...ExpressLightningUnlockWalletApiSpec,
...ExpressOfcSignPayloadApiSpec,
...ExpressWalletRecoverTokenApiSpec,
...ExpressCoinSigningApiSpec,
...ExpressWalletSigningApiSpec,
...ExpressWalletManagementApiSpec,
};

type ExtractDecoded<T> = T extends t.Type<any, infer O, any> ? O : never;
Expand Down
174 changes: 174 additions & 0 deletions modules/express/src/typedRoutes/api/v2/coinSign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types';
import { BitgoExpressError } from '../../schemas/error';

/**
* Request parameters for signing a transaction (external signer mode)
*/
export const CoinSignParams = {
/** The coin type */
coin: t.string,
} as const;

/**
* Transaction prebuild information for external signing
* Requires walletId to retrieve encrypted private key from filesystem
*/
export const TransactionPrebuildForExternalSigning = t.intersection([
t.type({
/** Wallet ID - required for retrieving encrypted private key */
walletId: t.string,
}),
t.partial({
/** Transaction in hex format */
txHex: t.string,
/** Transaction in base64 format (for some coins) */
txBase64: t.string,
/** Transaction in JSON format (for some coins) */
txInfo: t.any,
/** Next contract sequence ID (for ETH) */
nextContractSequenceId: t.number,
/** Whether this is a batch transaction (for ETH) */
isBatch: t.boolean,
/** EIP1559 transaction parameters (for ETH) */
eip1559: t.any,
/** Hop transaction data (for ETH) */
hopTransaction: t.any,
/** Backup key nonce (for ETH) */
backupKeyNonce: t.any,
/** Recipients of the transaction */
recipients: t.any,
}),
]);

/**
* Request body for signing a transaction in external signer mode
*
* This route is used when BitGo Express is configured with external signing.
* The private key is retrieved from the filesystem and decrypted using
* a wallet passphrase stored in the environment variable WALLET_{walletId}_PASSPHRASE.
*/
export const CoinSignBody = {
/** Transaction prebuild data - must contain walletId */
txPrebuild: TransactionPrebuildForExternalSigning,
/**
* Derivation seed for deriving a child key from the main private key.
* If provided, the key will be derived using coin.deriveKeyWithSeed()
*/
derivationSeed: optional(t.string),
/** Whether this is the last signature in a multi-sig tx */
isLastSignature: optional(t.boolean),
/** Gas limit for ETH transactions */
gasLimit: optional(t.union([t.string, t.number])),
/** Gas price for ETH transactions */
gasPrice: optional(t.union([t.string, t.number])),
/** Transaction expiration time */
expireTime: optional(t.number),
/** Sequence ID for transactions */
sequenceId: optional(t.number),
/** Public keys for multi-signature transactions */
pubKeys: optional(t.array(t.string)),
/** For EVM cross-chain recovery */
isEvmBasedCrossChainRecovery: optional(t.boolean),
/** Recipients of the transaction */
recipients: optional(t.any),
/** Custodian transaction ID */
custodianTransactionId: optional(t.string),
/** Signing step for MuSig2 */
signingStep: optional(t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')])),
/** Allow non-segwit signing without previous transaction */
allowNonSegwitSigningWithoutPrevTx: optional(t.boolean),
} as const;

/**
* Response for a fully signed transaction
*/
export const FullySignedTransactionResponse = t.type({
/** Transaction in hex format */
txHex: t.string,
});

/**
* Response for a half-signed account transaction
*/
export const HalfSignedAccountTransactionResponse = t.partial({
halfSigned: t.partial({
txHex: t.string,
payload: t.string,
txBase64: t.string,
}),
});

/**
* Response for a half-signed UTXO transaction
*/
export const HalfSignedUtxoTransactionResponse = t.type({
txHex: t.string,
});

/**
* Response for a transaction request
*/
export const SignedTransactionRequestResponse = t.type({
txRequestId: t.string,
});

/**
* Response for signing a transaction in external signer mode
*
* The response format matches coin.signTransaction() and varies based on:
* - Whether the transaction is fully or half-signed
* - The coin type (UTXO vs Account-based)
* - Whether TSS is used (returns TxRequestResponse)
*/
export const CoinSignResponse = {
/** Successfully signed transaction */
200: t.union([
FullySignedTransactionResponse,
HalfSignedAccountTransactionResponse,
HalfSignedUtxoTransactionResponse,
SignedTransactionRequestResponse,
TxRequestResponse,
]),
/** Error response - validation or signing errors */
400: BitgoExpressError,
};

/**
* Sign a transaction using external signer mode
*
* This endpoint is used when BitGo Express is configured with external signing
* (signerFileSystemPath config is set). It:
*
* 1. Retrieves the encrypted private key from the filesystem using walletId
* 2. Decrypts it using the wallet passphrase from environment (WALLET_{walletId}_PASSPHRASE)
* 3. Optionally derives a child key if derivationSeed is provided
* 4. Signs the transaction using the private key
*
* **Configuration Requirements:**
* - `signerFileSystemPath`: Path to JSON file containing encrypted private keys
* - Environment variable: `WALLET_{walletId}_PASSPHRASE` for each wallet
*
* **Request Body:**
* - `txPrebuild`: Transaction prebuild data (must include walletId)
* - `derivationSeed`: Optional seed for deriving a child key
* - Other fields are passed to coin.signTransaction()
*
* **Response:**
* - Fully signed transaction (if all signatures collected)
* - Half-signed transaction (if more signatures needed)
* - Transaction request ID (for TSS wallets)
*
* @tag express
* @operationId express.v2.coin.sign
*/
export const PostCoinSign = httpRoute({
path: '/api/v2/{coin}/sign',
method: 'POST',
request: httpRequest({
params: CoinSignParams,
body: CoinSignBody,
}),
response: CoinSignResponse,
});
3 changes: 2 additions & 1 deletion modules/express/test/lib/testutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ export function unlockToken(agent, accessToken, seconds) {
});
}

export function setupAgent(): request.SuperAgentTest {
export function setupAgent(config?: any): request.SuperAgentTest {
const args: any = {
debug: false,
env: 'test',
logfile: '/dev/null',
...config,
};

const app = expressApp(args);
Expand Down
8 changes: 7 additions & 1 deletion modules/express/test/unit/clientRoutes/externalSign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,16 @@ describe('External signer', () => {
params: {
coin: 'tbtc',
},
decoded: {
coin: 'tbtc',
txPrebuild: {
walletId: walletId,
},
},
config: {
signerFileSystemPath: 'signerFileSystemPath',
},
} as unknown as express.Request;
} as any;

await handleV2Sign(req);

Expand Down
Loading