diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 04a13e4e4c..e6c7903ba5 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -257,7 +257,7 @@ function handleApproveTransaction(req: ExpressApiRouteRequest<'express.v1.pendin * @deprecated * @param req */ -function handleConstructApprovalTx(req: express.Request) { +function handleConstructApprovalTx(req: ExpressApiRouteRequest<'express.v1.pendingapproval.constructTx', 'put'>) { const params = req.body || {}; return req.bitgo .pendingApprovals() @@ -1593,12 +1593,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.put('express.v1.pendingapprovals', [prepareBitGo(config), typedPromiseWrapper(handleApproveTransaction)]); - app.put( - '/api/v1/pendingapprovals/:id/constructTx', - parseBody, + router.put('express.v1.pendingapproval.constructTx', [ prepareBitGo(config), - promiseWrapper(handleConstructApprovalTx) - ); + typedPromiseWrapper(handleConstructApprovalTx), + ]); app.put( '/api/v1/wallet/:id/consolidateunspents', diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 11e6b33c20..cfe0276fc0 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -20,6 +20,7 @@ import { PostUnlockLightningWallet } from './v2/unlockWallet'; import { PostVerifyCoinAddress } from './v2/verifyAddress'; import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain'; import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain'; +import { PutConstructPendingApprovalTx } from './v1/constructPendingApprovalTx'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -76,6 +77,9 @@ export const ExpressApi = apiSpec({ 'express.v1.keychain.local': { post: PostCreateLocalKeyChain, }, + 'express.v1.pendingapproval.constructTx': { + put: PutConstructPendingApprovalTx, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v1/constructPendingApprovalTx.ts b/modules/express/src/typedRoutes/api/v1/constructPendingApprovalTx.ts new file mode 100644 index 0000000000..bfd51364ee --- /dev/null +++ b/modules/express/src/typedRoutes/api/v1/constructPendingApprovalTx.ts @@ -0,0 +1,77 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Request parameters for constructing a pending approval transaction + */ +export const ConstructPendingApprovalTxRequestParams = { + /** The ID of the pending approval */ + id: t.string, +}; + +/** + * Request body for constructing a pending approval transaction + */ +export const ConstructPendingApprovalTxRequestBody = { + /** The wallet passphrase to decrypt the user key (either walletPassphrase or xprv must be provided for transactionRequest type) */ + walletPassphrase: optional(t.string), + /** The extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + /** Whether to use the original fee from the transaction request (cannot be used with fee, feeRate, or feeTxConfirmTarget) */ + useOriginalFee: optional(t.boolean), + /** Custom fee amount in satoshis (cannot be used with useOriginalFee) */ + fee: optional(t.number), + /** Custom fee rate in satoshis per kilobyte (cannot be used with useOriginalFee) */ + feeRate: optional(t.number), + /** Custom fee confirmation target in blocks (cannot be used with useOriginalFee) */ + feeTxConfirmTarget: optional(t.number), +}; + +/** + * Response for constructing a pending approval transaction + */ +export const ConstructPendingApprovalTxResponse = t.type({ + /** The signed transaction hex */ + tx: t.string, + /** The fee amount in satoshis */ + fee: optional(t.number), + /** The fee rate in satoshis per kilobyte */ + feeRate: optional(t.number), + /** Whether the transaction is instant */ + instant: optional(t.boolean), + /** The BitGo fee amount */ + bitgoFee: optional(t.unknown), + /** Travel information */ + travelInfos: optional(t.unknown), + /** Estimated transaction size in bytes */ + estimatedSize: optional(t.number), + /** Unspent transaction outputs used */ + unspents: optional(t.array(t.unknown)), +}); + +/** + * Construct a pending approval transaction + * + * This endpoint constructs and signs a transaction for a pending approval, returning the transaction hex + * but not sending it to the network. This is useful for reviewing the transaction before approving it. + * + * For transaction request type approvals, either a wallet passphrase or xprv must be provided to sign the transaction. + * You can optionally specify fee-related parameters to customize the transaction fee. + * + * @operationId express.v1.pendingapproval.constructTx + */ +export const PutConstructPendingApprovalTx = httpRoute({ + path: '/api/v1/pendingapprovals/:id/constructTx', + method: 'PUT', + request: httpRequest({ + params: ConstructPendingApprovalTxRequestParams, + body: ConstructPendingApprovalTxRequestBody, + }), + response: { + /** Successfully constructed transaction */ + 200: ConstructPendingApprovalTxResponse, + /** Invalid request or construction fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/constructPendingApprovalTx.ts b/modules/express/test/unit/typedRoutes/constructPendingApprovalTx.ts new file mode 100644 index 0000000000..21a59dd8d9 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/constructPendingApprovalTx.ts @@ -0,0 +1,305 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + ConstructPendingApprovalTxRequestParams, + ConstructPendingApprovalTxRequestBody, + ConstructPendingApprovalTxResponse, + PutConstructPendingApprovalTx, +} from '../../../src/typedRoutes/api/v1/constructPendingApprovalTx'; +import { assertDecode } from './common'; + +describe('ConstructPendingApprovalTx codec tests', function () { + describe('ConstructPendingApprovalTxRequestParams', function () { + it('should validate params with required id', function () { + const validParams = { + id: '123456789abcdef', + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestParams), validParams); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing id', function () { + const invalidParams = {}; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + id: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestParams), invalidParams); + }); + }); + }); + + describe('ConstructPendingApprovalTxRequestBody', function () { + it('should validate empty body', function () { + const validBody = {}; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.walletPassphrase, undefined); + assert.strictEqual(decoded.xprv, undefined); + assert.strictEqual(decoded.useOriginalFee, undefined); + assert.strictEqual(decoded.fee, undefined); + assert.strictEqual(decoded.feeRate, undefined); + assert.strictEqual(decoded.feeTxConfirmTarget, undefined); + }); + + it('should validate body with walletPassphrase', function () { + const validBody = { + walletPassphrase: 'mySecurePassphrase', + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + }); + + it('should validate body with xprv', function () { + const validBody = { + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.xprv, validBody.xprv); + }); + + it('should validate body with useOriginalFee', function () { + const validBody = { + useOriginalFee: true, + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.useOriginalFee, validBody.useOriginalFee); + }); + + it('should validate body with fee', function () { + const validBody = { + fee: 10000, + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.fee, validBody.fee); + }); + + it('should validate body with feeRate', function () { + const validBody = { + feeRate: 20000, + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.feeRate, validBody.feeRate); + }); + + it('should validate body with feeTxConfirmTarget', function () { + const validBody = { + feeTxConfirmTarget: 2, + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.feeTxConfirmTarget, validBody.feeTxConfirmTarget); + }); + + it('should validate body with all fields', function () { + const validBody = { + walletPassphrase: 'mySecurePassphrase', + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + useOriginalFee: true, + fee: 10000, + feeRate: 20000, + feeTxConfirmTarget: 2, + }; + + const decoded = assertDecode(t.type(ConstructPendingApprovalTxRequestBody), validBody); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + assert.strictEqual(decoded.xprv, validBody.xprv); + assert.strictEqual(decoded.useOriginalFee, validBody.useOriginalFee); + assert.strictEqual(decoded.fee, validBody.fee); + assert.strictEqual(decoded.feeRate, validBody.feeRate); + assert.strictEqual(decoded.feeTxConfirmTarget, validBody.feeTxConfirmTarget); + }); + + it('should reject body with non-string walletPassphrase', function () { + const invalidBody = { + walletPassphrase: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string xprv', function () { + const invalidBody = { + xprv: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-boolean useOriginalFee', function () { + const invalidBody = { + useOriginalFee: 'true', // string instead of boolean + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-number fee', function () { + const invalidBody = { + fee: '10000', // string instead of number + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-number feeRate', function () { + const invalidBody = { + feeRate: '20000', // string instead of number + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-number feeTxConfirmTarget', function () { + const invalidBody = { + feeTxConfirmTarget: '2', // string instead of number + }; + + assert.throws(() => { + assertDecode(t.type(ConstructPendingApprovalTxRequestBody), invalidBody); + }); + }); + }); + + describe('ConstructPendingApprovalTxResponse', function () { + it('should validate response with required tx field', function () { + const validResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + const decoded = assertDecode(ConstructPendingApprovalTxResponse, validResponse); + assert.strictEqual(decoded.tx, validResponse.tx); + }); + + it('should validate response with all fields', function () { + const validResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + fee: 10000, + feeRate: 20000, + instant: false, + bitgoFee: { amount: 5000, address: '1BitGo...' }, + travelInfos: [{ fromAddress: '1From...', toAddress: '1To...', amount: 1000000 }], + estimatedSize: 256, + unspents: [{ id: 'unspent1', value: 1000000 }], + }; + + const decoded = assertDecode(ConstructPendingApprovalTxResponse, validResponse); + assert.strictEqual(decoded.tx, validResponse.tx); + assert.strictEqual(decoded.fee, validResponse.fee); + assert.strictEqual(decoded.feeRate, validResponse.feeRate); + assert.strictEqual(decoded.instant, validResponse.instant); + assert.deepStrictEqual(decoded.bitgoFee, validResponse.bitgoFee); + assert.deepStrictEqual(decoded.travelInfos, validResponse.travelInfos); + assert.strictEqual(decoded.estimatedSize, validResponse.estimatedSize); + assert.deepStrictEqual(decoded.unspents, validResponse.unspents); + }); + + it('should reject response with missing tx', function () { + const invalidResponse = { + fee: 10000, + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + + it('should reject response with non-string tx', function () { + const invalidResponse = { + tx: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + + it('should reject response with non-number fee', function () { + const invalidResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + fee: '10000', // string instead of number + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + + it('should reject response with non-number feeRate', function () { + const invalidResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + feeRate: '20000', // string instead of number + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + + it('should reject response with non-boolean instant', function () { + const invalidResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + instant: 'false', // string instead of boolean + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + it('should reject response with non-number estimatedSize', function () { + const invalidResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + estimatedSize: '256', // string instead of number + }; + + assert.throws(() => { + assertDecode(ConstructPendingApprovalTxResponse, invalidResponse); + }); + }); + }); + + describe('PutConstructPendingApprovalTx route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PutConstructPendingApprovalTx.path, '/api/v1/pendingapprovals/:id/constructTx'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PutConstructPendingApprovalTx.method, 'PUT'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PutConstructPendingApprovalTx.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PutConstructPendingApprovalTx.response[200]); + assert.ok(PutConstructPendingApprovalTx.response[400]); + }); + }); +});