From e53a947580b771868e721f9b65500512edfa57c3 Mon Sep 17 00:00:00 2001 From: Lokesh Chandra Date: Mon, 3 Nov 2025 18:08:24 +0530 Subject: [PATCH] feat(express): migrated coinSign as type route Ticket: WP-5441 --- modules/express/src/clientRoutes.ts | 9 +- modules/express/src/typedRoutes/api/index.ts | 26 +- .../src/typedRoutes/api/v2/coinSign.ts | 174 ++++ modules/express/test/lib/testutil.ts | 3 +- .../test/unit/clientRoutes/externalSign.ts | 8 +- .../express/test/unit/typedRoutes/coinSign.ts | 870 ++++++++++++++++++ 6 files changed, 1084 insertions(+), 6 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/coinSign.ts create mode 100644 modules/express/test/unit/typedRoutes/coinSign.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 35e2e8aae8..698538d946 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -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) { @@ -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; } @@ -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, diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 5dd44e82bd..444b29e610 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -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 // @@ -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, }, @@ -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, @@ -263,6 +283,10 @@ export const ExpressApi: ExpressApi = { ...ExpressLightningInitWalletApiSpec, ...ExpressLightningUnlockWalletApiSpec, ...ExpressOfcSignPayloadApiSpec, + ...ExpressWalletRecoverTokenApiSpec, + ...ExpressCoinSigningApiSpec, + ...ExpressWalletSigningApiSpec, + ...ExpressWalletManagementApiSpec, }; type ExtractDecoded = T extends t.Type ? O : never; diff --git a/modules/express/src/typedRoutes/api/v2/coinSign.ts b/modules/express/src/typedRoutes/api/v2/coinSign.ts new file mode 100644 index 0000000000..1315659e58 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/coinSign.ts @@ -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, +}); diff --git a/modules/express/test/lib/testutil.ts b/modules/express/test/lib/testutil.ts index 886998f81d..426cc6d0c8 100644 --- a/modules/express/test/lib/testutil.ts +++ b/modules/express/test/lib/testutil.ts @@ -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); diff --git a/modules/express/test/unit/clientRoutes/externalSign.ts b/modules/express/test/unit/clientRoutes/externalSign.ts index 7ff144dd3a..5c343c7097 100644 --- a/modules/express/test/unit/clientRoutes/externalSign.ts +++ b/modules/express/test/unit/clientRoutes/externalSign.ts @@ -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); diff --git a/modules/express/test/unit/typedRoutes/coinSign.ts b/modules/express/test/unit/typedRoutes/coinSign.ts new file mode 100644 index 0000000000..94985cfa2b --- /dev/null +++ b/modules/express/test/unit/typedRoutes/coinSign.ts @@ -0,0 +1,870 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + CoinSignParams, + TransactionPrebuildForExternalSigning, + CoinSignBody, + FullySignedTransactionResponse, + HalfSignedAccountTransactionResponse, + HalfSignedUtxoTransactionResponse, + SignedTransactionRequestResponse, +} from '../../../src/typedRoutes/api/v2/coinSign'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import { setupAgent } from '../../lib/testutil'; + +describe('CoinSign codec tests (External Signer Mode)', function () { + describe('coinSign', function () { + const coin = 'tbtc'; + const walletId = '5a1341e7c8421dc90710673b3166bbd5'; + const encryptedPrivKey = + 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2:encrypted'; + const decryptedPrivKey = + 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2'; + const walletPassphrase = 'test_wallet_passphrase'; + // Use the existing encryptedPrivKeys.json file in the project root + const path = require('path'); + const signerFilePath = path.join(__dirname, '../../../encryptedPrivKeys.json'); + + let fsReadFileStub: sinon.SinonStub; + let agent: ReturnType; + let originalFileContent: string; + + // Mock encrypted private keys JSON content + const mockSignerFileContent = JSON.stringify({ + [walletId]: encryptedPrivKey, + }); + + // Setup the express app with signer mode before all tests + before(function () { + // Save the original content of encryptedPrivKeys.json + try { + originalFileContent = fsSync.readFileSync(signerFilePath, 'utf8'); + } catch (e) { + originalFileContent = '{}'; + } + + // Temporarily write mock data to the existing file + fsSync.writeFileSync(signerFilePath, mockSignerFileContent); + + // Create agent with signerMode enabled for external signing + agent = setupAgent({ + signerMode: true, + signerFileSystemPath: signerFilePath, + }); + }); + + // Restore the original file content after all tests + after(function () { + // Restore original content + fsSync.writeFileSync(signerFilePath, originalFileContent); + }); + + beforeEach(function () { + // Setup environment variable for wallet passphrase + process.env[`WALLET_${walletId}_PASSPHRASE`] = walletPassphrase; + }); + + afterEach(function () { + // Restore ALL sinon stubs to prevent conflicts between tests + sinon.restore(); + // Clean up environment variables + delete process.env[`WALLET_${walletId}_PASSPHRASE`]; + }); + + const mockFullySignedResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + it('should successfully sign a transaction in external signer mode', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + isLastSignature: true, + }; + + // Mock filesystem read for encrypted private key + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + // Create mock BitGo with decrypt method + const mockBitGo = { + decrypt: sinon.stub().returns(decryptedPrivKey), + coin: sinon.stub(), + }; + + // Create mock coin with signTransaction method + const mockCoin = { + signTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + mockBitGo.coin.returns(mockCoin); + + // Stub BitGo constructor + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').callsFake(mockBitGo.decrypt); + + // Make the request to Express + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txHex'); + assert.strictEqual(result.body.txHex, mockFullySignedResponse.txHex); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(FullySignedTransactionResponse, result.body); + assert.strictEqual(decodedResponse.txHex, mockFullySignedResponse.txHex); + + // Verify filesystem was accessed + assert.strictEqual(fsReadFileStub.calledOnce, true); + + // Verify private key was decrypted + assert.strictEqual(mockBitGo.decrypt.calledOnce, true); + assert.strictEqual(mockBitGo.decrypt.calledWith({ password: walletPassphrase, input: encryptedPrivKey }), true); + + // Verify signTransaction was called with decrypted key + assert.strictEqual(mockCoin.signTransaction.calledOnce, true); + const signTxCall = mockCoin.signTransaction.firstCall.args[0]; + assert.strictEqual(signTxCall.prv, decryptedPrivKey); + assert.strictEqual(signTxCall.isLastSignature, true); + }); + + it('should successfully sign with derivationSeed', async function () { + const derivationSeed = 'test-derivation-seed-123'; + const derivedKey = + 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2:derived'; + + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + derivationSeed: derivationSeed, + }; + + // Mock filesystem read + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + // Create mock coin with deriveKeyWithSeed + const mockCoin = { + signTransaction: sinon.stub().resolves(mockFullySignedResponse), + deriveKeyWithSeed: sinon.stub().returns({ + key: derivedKey, + derivationPath: "m/0'", + }), + }; + + // Stub BitGo methods + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txHex'); + + // Verify key derivation was called + assert.strictEqual(mockCoin.deriveKeyWithSeed.calledOnce, true); + assert.strictEqual( + mockCoin.deriveKeyWithSeed.calledWith({ + key: decryptedPrivKey, + seed: derivationSeed, + }), + true + ); + + // Verify signTransaction was called with derived key + const signTxCall = mockCoin.signTransaction.firstCall.args[0]; + assert.strictEqual(signTxCall.prv, derivedKey); + assert.strictEqual(signTxCall.derivationSeed, derivationSeed); + }); + + it('should successfully sign a half-signed account transaction', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txBase64: + 'AQAAAAFz2JT3Xvjk8jKcYcMrKR8tPMRm5+/Q6J2sMgtz7QDpAAAAAAD+////AoCWmAAAAAAAGXapFJA29QPQaHHwR3Uriuhw2A6tHkPgiKwAAAAAAAEBH9cQ2QAAAAAAAXapFCf/zr8zPrMftHGIRsOt0Cf+wdOyiKwA', + }, + }; + + const mockHalfSignedResponse = { + halfSigned: { + txBase64: + 'AQAAAAFz2JT3Xvjk8jKcYcMrKR8tPMRm5+/Q6J2sMgtz7QDpAAAAAAD+////AoCWmAAAAAAAGXapFJA29QPQaHHwR3Uriuhw2A6tHkPgiKwAAAAAAAEBH9cQ2QAAAAAAAXapFCf/zr8zPrMftHGIRsOt0Cf+wdOyiKwA', + }, + }; + + // Mock filesystem and decryption + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signTransaction: sinon.stub().resolves(mockHalfSignedResponse), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + result.body.should.have.property('halfSigned'); + result.body.halfSigned.should.have.property('txBase64'); + assert.strictEqual(result.body.halfSigned.txBase64, mockHalfSignedResponse.halfSigned.txBase64); + + // Validate against type definition + const decodedResponse = assertDecode(HalfSignedAccountTransactionResponse, result.body); + assert.strictEqual(decodedResponse.halfSigned?.txBase64, mockHalfSignedResponse.halfSigned.txBase64); + }); + + it('should successfully sign a half-signed UTXO transaction', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + const mockHalfSignedUtxoResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000049483045022100abc...ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + // Mock filesystem and decryption + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signTransaction: sinon.stub().resolves(mockHalfSignedUtxoResponse), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txHex'); + assert.strictEqual(result.body.txHex, mockHalfSignedUtxoResponse.txHex); + + // Validate against type definition + const decodedResponse = assertDecode(HalfSignedUtxoTransactionResponse, result.body); + assert.strictEqual(decodedResponse.txHex, mockHalfSignedUtxoResponse.txHex); + }); + + it('should successfully return a transaction request ID', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + const mockTxRequestResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + }; + + // Mock filesystem and decryption + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signTransaction: sinon.stub().resolves(mockTxRequestResponse), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + assert.strictEqual(result.body.txRequestId, mockTxRequestResponse.txRequestId); + + // Validate against type definition + const decodedResponse = assertDecode(SignedTransactionRequestResponse, result.body); + assert.strictEqual(decodedResponse.txRequestId, mockTxRequestResponse.txRequestId); + }); + + it('should successfully return a TSS transaction request (Full TxRequestResponse)', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + const mockTxRequestFullResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + walletId: walletId, + walletType: 'hot', + version: 1, + state: 'signed', + date: '2023-01-01T00:00:00.000Z', + userId: '5a1341e7c8421dc90710673b3166bbd5', + intent: {}, + pendingApprovalId: '5a1341e7c8421dc90710673b3166bbd5', + policiesChecked: true, + signatureShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + }, + ], + unsignedTxs: [ + { + serializedTxHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + signableHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + derivationPath: "m/44'/0'/0'/0/0", + }, + ], + apiVersion: 'lite', + latest: true, + }; + + // Mock filesystem and decryption + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signTransaction: sinon.stub().resolves(mockTxRequestFullResponse), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + result.body.should.have.property('walletId'); + result.body.should.have.property('version'); + result.body.should.have.property('state'); + result.body.should.have.property('latest'); + assert.strictEqual(result.body.txRequestId, mockTxRequestFullResponse.txRequestId); + assert.strictEqual(result.body.walletId, mockTxRequestFullResponse.walletId); + assert.strictEqual(result.body.latest, mockTxRequestFullResponse.latest); + + // Verify TSS-specific fields + result.body.should.have.property('signatureShares'); + result.body.should.have.property('unsignedTxs'); + result.body.signatureShares.should.be.Array(); + result.body.unsignedTxs.should.be.Array(); + + // Validate against type definition + const decodedResponse = assertDecode(SignedTransactionRequestResponse, result.body); + assert.strictEqual(decodedResponse.txRequestId, mockTxRequestFullResponse.txRequestId); + }); + + it('should fail when walletId is missing from txPrebuild', async function () { + const requestBody = { + txPrebuild: { + // Missing walletId + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - typed router returns validation errors with status 400 + assert.strictEqual(result.status, 400); + // The error response contains a validation error message + assert.ok(result.body); + }); + + it('should fail when txPrebuild is missing', async function () { + const requestBody = { + // Missing txPrebuild entirely + isLastSignature: true, + }; + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - typed router returns validation errors with status 400 + assert.strictEqual(result.status, 400); + // The error response contains a validation error message + assert.ok(result.body); + }); + + it('should fail when wallet passphrase environment variable is missing', async function () { + // Remove the wallet passphrase from environment + delete process.env[`WALLET_${walletId}_PASSPHRASE`]; + + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - runtime errors return 500 + assert.strictEqual(result.status, 500); + assert.ok(result.body); + }); + + it('should fail when encrypted private key file cannot be read', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Mock filesystem read to fail + fsReadFileStub = sinon.stub(fs, 'readFile').rejects(new Error('ENOENT: File not found')); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - runtime errors return 500 + assert.strictEqual(result.status, 500); + assert.ok(result.body); + }); + + it('should fail when walletId is not found in encrypted private keys file', async function () { + const differentWalletId = 'different-wallet-id-123'; + + const requestBody = { + txPrebuild: { + walletId: differentWalletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Setup environment for different wallet + process.env[`WALLET_${differentWalletId}_PASSPHRASE`] = 'test-pass'; + + // Mock filesystem with different wallet IDs + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, // Different wallet ID + }) + ); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - runtime errors return 500 + assert.strictEqual(result.status, 500); + assert.ok(result.body); + + // Cleanup + delete process.env[`WALLET_${differentWalletId}_PASSPHRASE`]; + }); + + it('should fail when private key decryption fails', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Mock filesystem read + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + // Mock decrypt to throw error + sinon.stub(BitGo.prototype, 'decrypt').throws(new Error('Invalid passphrase')); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - runtime errors return 500 + assert.strictEqual(result.status, 500); + assert.ok(result.body); + }); + + it('should fail when coin.signTransaction throws an error', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + // Mock filesystem and decryption + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signTransaction: sinon.stub().rejects(new Error('Invalid transaction format')), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response - runtime errors return 500 + assert.strictEqual(result.status, 500); + assert.ok(result.body); + }); + }); + + describe('CoinSignParams', function () { + it('should validate params with required coin', function () { + const validParams = { + coin: 'tbtc', + }; + const decoded = assertDecode(t.type(CoinSignParams), validParams); + assert.strictEqual(decoded.coin, 'tbtc'); + }); + + it('should fail validation when coin is missing', function () { + const invalidParams = {}; + assert.throws(() => { + assertDecode(t.type(CoinSignParams), invalidParams); + }); + }); + }); + + describe('TransactionPrebuildForExternalSigning', function () { + const walletId = '5a1341e7c8421dc90710673b3166bbd5'; + + it('should validate prebuild with required walletId', function () { + const validPrebuild = { + walletId: walletId, + }; + const decoded = assertDecode(TransactionPrebuildForExternalSigning, validPrebuild); + assert.strictEqual(decoded.walletId, walletId); + }); + + it('should validate prebuild with all optional fields', function () { + const validPrebuild = { + walletId: walletId, + txHex: '0100000001...', + txBase64: 'AQAAA...', + txInfo: { memo: 'test' }, + nextContractSequenceId: 123, + isBatch: true, + eip1559: { maxFeePerGas: '50000000000' }, + hopTransaction: { tx: 'hop-tx' }, + backupKeyNonce: { nonce: 1 }, + recipients: [{ address: '0x123', amount: 1000 }], + }; + const decoded = assertDecode(TransactionPrebuildForExternalSigning, validPrebuild); + assert.strictEqual(decoded.walletId, walletId); + assert.strictEqual(decoded.txHex, '0100000001...'); + assert.strictEqual(decoded.isBatch, true); + }); + + it('should fail validation when walletId is missing', function () { + const invalidPrebuild = { + txHex: '0100000001...', + }; + assert.throws(() => { + assertDecode(TransactionPrebuildForExternalSigning, invalidPrebuild); + }); + }); + }); + + describe('CoinSignBody', function () { + const walletId = '5a1341e7c8421dc90710673b3166bbd5'; + + it('should validate body with required txPrebuild', function () { + const validBody = { + txPrebuild: { + walletId: walletId, + }, + }; + const decoded = assertDecode(t.type(CoinSignBody), validBody); + assert.strictEqual(decoded.txPrebuild.walletId, walletId); + }); + + it('should validate body with all optional fields', function () { + const validBody = { + txPrebuild: { + walletId: walletId, + txHex: '0100000001...', + }, + derivationSeed: 'test-seed', + isLastSignature: true, + gasLimit: 21000, + gasPrice: '50000000000', + expireTime: 1234567890, + sequenceId: 1, + pubKeys: ['pubkey1', 'pubkey2'], + isEvmBasedCrossChainRecovery: false, + recipients: [{ address: '0x123', amount: 1000 }], + custodianTransactionId: 'cust-123', + signingStep: 'signerNonce', + allowNonSegwitSigningWithoutPrevTx: true, + }; + const decoded = assertDecode(t.type(CoinSignBody), validBody); + assert.strictEqual(decoded.txPrebuild.walletId, walletId); + assert.strictEqual(decoded.derivationSeed, 'test-seed'); + assert.strictEqual(decoded.isLastSignature, true); + assert.strictEqual(decoded.gasLimit, 21000); + }); + + it('should validate body with gasLimit and gasPrice as different types', function () { + const validBody = { + txPrebuild: { walletId: walletId }, + gasLimit: 21000, // as number + gasPrice: '50000000000', // as string + }; + const decoded = assertDecode(t.type(CoinSignBody), validBody); + assert.strictEqual(decoded.gasLimit, 21000); + assert.strictEqual(decoded.gasPrice, '50000000000'); + + const validBody2 = { + txPrebuild: { walletId: walletId }, + gasLimit: '21000', // as string + gasPrice: 50000000000, // as number + }; + const decoded2 = assertDecode(t.type(CoinSignBody), validBody2); + assert.strictEqual(decoded2.gasLimit, '21000'); + assert.strictEqual(decoded2.gasPrice, 50000000000); + }); + + it('should fail validation when txPrebuild is missing', function () { + const invalidBody = { + isLastSignature: true, + }; + assert.throws(() => { + assertDecode(t.type(CoinSignBody), invalidBody); + }); + }); + }); + + describe('Response Codecs', function () { + describe('FullySignedTransactionResponse', function () { + it('should validate response with required txHex', function () { + const validResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + const decoded = assertDecode(FullySignedTransactionResponse, validResponse); + assert.strictEqual(decoded.txHex, validResponse.txHex); + }); + + it('should fail validation when txHex is missing', function () { + const invalidResponse = {}; + assert.throws(() => { + assertDecode(FullySignedTransactionResponse, invalidResponse); + }); + }); + }); + + describe('HalfSignedAccountTransactionResponse', function () { + it('should validate response with all halfSigned fields', function () { + const validResponse = { + halfSigned: { + txHex: '0x123...', + payload: 'payload-data', + txBase64: 'base64-data', + }, + }; + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned?.txHex, '0x123...'); + assert.strictEqual(decoded.halfSigned?.payload, 'payload-data'); + assert.strictEqual(decoded.halfSigned?.txBase64, 'base64-data'); + }); + + it('should validate response with empty halfSigned', function () { + const validResponse = { + halfSigned: {}, + }; + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.deepStrictEqual(decoded.halfSigned, {}); + }); + + it('should validate response without halfSigned (optional)', function () { + const validResponse = {}; + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned, undefined); + }); + }); + + describe('HalfSignedUtxoTransactionResponse', function () { + it('should validate response with required txHex', function () { + const validResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000049483045022100abc...ffffffff', + }; + const decoded = assertDecode(HalfSignedUtxoTransactionResponse, validResponse); + assert.strictEqual(decoded.txHex, validResponse.txHex); + }); + + it('should fail validation when txHex is missing', function () { + const invalidResponse = {}; + assert.throws(() => { + assertDecode(HalfSignedUtxoTransactionResponse, invalidResponse); + }); + }); + }); + + describe('SignedTransactionRequestResponse', function () { + it('should validate response with required txRequestId', function () { + const validResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + }; + const decoded = assertDecode(SignedTransactionRequestResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + }); + + it('should validate response with all optional TSS fields', function () { + const validResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + walletId: '5a1341e7c8421dc90710673b3166bbd5', + version: 1, + latest: true, + state: 'signed', + date: '2023-01-01T00:00:00.000Z', + userId: 'user-123', + intent: {}, + signatureShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + }, + ], + unsignedTxs: [ + { + serializedTxHex: '0100000001...', + signableHex: '0100000001...', + }, + ], + }; + const decoded = assertDecode(SignedTransactionRequestResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + + if ('walletId' in decoded) { + assert.strictEqual(decoded.walletId, validResponse.walletId); + } + if ('latest' in decoded) { + assert.strictEqual(decoded.latest, true); + } + }); + + it('should fail validation when txRequestId is missing', function () { + const invalidResponse = { + walletId: '5a1341e7c8421dc90710673b3166bbd5', + }; + assert.throws(() => { + assertDecode(SignedTransactionRequestResponse, invalidResponse); + }); + }); + }); + }); +});