From 05b3b85c714a2caa5ca3eb63495e3b0a5d347ea8 Mon Sep 17 00:00:00 2001 From: Lokesh Chandra Date: Wed, 1 Oct 2025 12:43:17 +0530 Subject: [PATCH] feat(express): migrate fanoutunspents to typed routes Ticket: WP-5413 --- modules/express/src/clientRoutes.ts | 4 +- modules/express/src/typedRoutes/api/index.ts | 4 + .../src/typedRoutes/api/v1/fanoutUnspents.ts | 77 ++++ .../test/unit/typedRoutes/fanoutUnspents.ts | 394 ++++++++++++++++++ 4 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v1/fanoutUnspents.ts create mode 100644 modules/express/test/unit/typedRoutes/fanoutUnspents.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 90687ec2a1..6c12612b03 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -284,7 +284,7 @@ function handleConsolidateUnspents(req: ExpressApiRouteRequest<'express.v1.walle * @deprecated * @param req */ -function handleFanOutUnspents(req: express.Request) { +function handleFanOutUnspents(req: ExpressApiRouteRequest<'express.v1.wallet.fanoutunspents', 'put'>) { return req.bitgo .wallets() .get({ id: req.params.id }) @@ -1603,7 +1603,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { typedPromiseWrapper(handleConsolidateUnspents), ]); - app.put('/api/v1/wallet/:id/fanoutunspents', parseBody, prepareBitGo(config), promiseWrapper(handleFanOutUnspents)); + router.put('express.v1.wallet.fanoutunspents', [prepareBitGo(config), typedPromiseWrapper(handleFanOutUnspents)]); // any other API call app.use('/api/v[1]/*', parseBody, prepareBitGo(config), promiseWrapper(handleREST)); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 47eecfed79..0f8c669694 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -22,6 +22,7 @@ import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain'; import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain'; import { PutConstructPendingApprovalTx } from './v1/constructPendingApprovalTx'; import { PutConsolidateUnspents } from './v1/consolidateUnspents'; +import { PutFanoutUnspents } from './v1/fanoutUnspents'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -84,6 +85,9 @@ export const ExpressApi = apiSpec({ 'express.v1.wallet.consolidateunspents': { put: PutConsolidateUnspents, }, + 'express.v1.wallet.fanoutunspents': { + put: PutFanoutUnspents, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v1/fanoutUnspents.ts b/modules/express/src/typedRoutes/api/v1/fanoutUnspents.ts new file mode 100644 index 0000000000..54bd94e71a --- /dev/null +++ b/modules/express/src/typedRoutes/api/v1/fanoutUnspents.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 fanning out unspents in a wallet + */ +export const FanoutUnspentsRequestParams = { + /** The ID of the wallet */ + id: t.string, +}; + +/** + * Request body for fanning out unspents in a wallet + */ +export const FanoutUnspentsRequestBody = { + /** The wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + /** The extended private key (alternative to walletPassphrase) */ + xprv: optional(t.string), + /** Whether to validate addresses (defaults to true) */ + validate: optional(t.boolean), + /** Target number of unspents to create (must be at least 2 and less than 300) */ + target: t.number, + /** Minimum number of confirmations needed for an unspent to be included (defaults to 1) */ + minConfirms: optional(t.number), +}; + +/** + * Response for fanning out unspents in a wallet + */ +export const FanoutUnspentsResponse = t.type({ + /** The status of the transaction ('accepted', 'pendingApproval', or 'otp') */ + status: t.string, + /** The transaction hex */ + tx: t.string, + /** The transaction hash/ID */ + hash: t.string, + /** Whether the transaction is instant */ + instant: t.boolean, + /** The instant ID (if applicable) */ + instantId: optional(t.string), + /** The fee amount in satoshis */ + fee: t.number, + /** The fee rate in satoshis per kilobyte */ + feeRate: t.number, + /** Travel rule information */ + travelInfos: t.unknown, + /** BitGo fee information (if applicable) */ + bitgoFee: optional(t.unknown), + /** Travel rule result (if applicable) */ + travelResult: optional(t.unknown), +}); + +/** + * Fan out unspents in a wallet + * + * This endpoint fans out unspents in a wallet by creating a transaction that spends from + * multiple inputs to multiple outputs. This is useful for increasing the number of UTXOs + * in a wallet, which can improve transaction parallelization. + * + * @operationId express.v1.wallet.fanoutunspents + */ +export const PutFanoutUnspents = httpRoute({ + path: '/api/v1/wallet/:id/fanoutunspents', + method: 'PUT', + request: httpRequest({ + params: FanoutUnspentsRequestParams, + body: FanoutUnspentsRequestBody, + }), + response: { + /** Successfully fanned out unspents */ + 200: FanoutUnspentsResponse, + /** Invalid request or fan out fails */ + 400: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/fanoutUnspents.ts b/modules/express/test/unit/typedRoutes/fanoutUnspents.ts new file mode 100644 index 0000000000..f1436f312f --- /dev/null +++ b/modules/express/test/unit/typedRoutes/fanoutUnspents.ts @@ -0,0 +1,394 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + FanoutUnspentsRequestParams, + FanoutUnspentsRequestBody, + FanoutUnspentsResponse, + PutFanoutUnspents, +} from '../../../src/typedRoutes/api/v1/fanoutUnspents'; +import { assertDecode } from './common'; + +describe('FanoutUnspents codec tests', function () { + describe('FanoutUnspentsRequestParams', function () { + it('should validate params with required id', function () { + const validParams = { + id: '123456789abcdef', + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestParams), validParams); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing id', function () { + const invalidParams = {}; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + id: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestParams), invalidParams); + }); + }); + }); + + describe('FanoutUnspentsRequestBody', function () { + it('should validate body with required target', function () { + const validBody = { + target: 10, + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.walletPassphrase, undefined); + assert.strictEqual(decoded.xprv, undefined); + assert.strictEqual(decoded.validate, undefined); + assert.strictEqual(decoded.minConfirms, undefined); + }); + + it('should reject body without target', function () { + const invalidBody = {}; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + + it('should validate body with walletPassphrase', function () { + const validBody = { + target: 10, + walletPassphrase: 'mySecurePassphrase', + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + }); + + it('should validate body with xprv', function () { + const validBody = { + target: 10, + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.xprv, validBody.xprv); + }); + + it('should validate body with validate flag', function () { + const validBody = { + target: 10, + validate: false, + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.validate, validBody.validate); + }); + + it('should validate body with minConfirms', function () { + const validBody = { + target: 10, + minConfirms: 2, + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.minConfirms, validBody.minConfirms); + }); + + it('should validate body with all fields', function () { + const validBody = { + target: 10, + walletPassphrase: 'mySecurePassphrase', + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + validate: true, + minConfirms: 2, + }; + + const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody); + assert.strictEqual(decoded.target, validBody.target); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + assert.strictEqual(decoded.xprv, validBody.xprv); + assert.strictEqual(decoded.validate, validBody.validate); + assert.strictEqual(decoded.minConfirms, validBody.minConfirms); + }); + + it('should reject body with non-number target', function () { + const invalidBody = { + target: '10', // string instead of number + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string walletPassphrase', function () { + const invalidBody = { + target: 10, + walletPassphrase: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + + it('should reject body with non-string xprv', function () { + const invalidBody = { + target: 10, + xprv: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + + it('should reject body with non-boolean validate', function () { + const invalidBody = { + target: 10, + validate: 'true', // string instead of boolean + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + + it('should reject body with non-number minConfirms', function () { + const invalidBody = { + target: 10, + minConfirms: '2', // string instead of number + }; + + assert.throws(() => { + assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody); + }); + }); + }); + + describe('FanoutUnspentsResponse', function () { + it('should validate response with required fields', function () { + const validResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + const decoded = assertDecode(FanoutUnspentsResponse, validResponse); + assert.strictEqual(decoded.status, validResponse.status); + assert.strictEqual(decoded.tx, validResponse.tx); + assert.strictEqual(decoded.hash, validResponse.hash); + assert.strictEqual(decoded.instant, validResponse.instant); + assert.strictEqual(decoded.fee, validResponse.fee); + assert.strictEqual(decoded.feeRate, validResponse.feeRate); + assert.deepStrictEqual(decoded.travelInfos, validResponse.travelInfos); + assert.strictEqual(decoded.instantId, undefined); // Optional field + assert.strictEqual(decoded.bitgoFee, undefined); // Optional field + assert.strictEqual(decoded.travelResult, undefined); // Optional field + }); + + it('should validate response with all fields including optional ones', function () { + const validResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: true, + instantId: 'inst-123456', + fee: 10000, + feeRate: 20000, + travelInfos: [{ fromAddress: '1From...', toAddress: '1To...', amount: 1000000 }], + bitgoFee: { amount: 5000, address: '1BitGo...' }, + travelResult: { compliance: 'pass' }, + }; + + const decoded = assertDecode(FanoutUnspentsResponse, validResponse); + assert.strictEqual(decoded.status, validResponse.status); + assert.strictEqual(decoded.tx, validResponse.tx); + assert.strictEqual(decoded.hash, validResponse.hash); + assert.strictEqual(decoded.instant, validResponse.instant); + assert.strictEqual(decoded.instantId, validResponse.instantId); + assert.strictEqual(decoded.fee, validResponse.fee); + assert.strictEqual(decoded.feeRate, validResponse.feeRate); + assert.deepStrictEqual(decoded.travelInfos, validResponse.travelInfos); + assert.deepStrictEqual(decoded.bitgoFee, validResponse.bitgoFee); + assert.deepStrictEqual(decoded.travelResult, validResponse.travelResult); + }); + + it('should reject response with missing status', function () { + const invalidResponse = { + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing tx', function () { + const invalidResponse = { + status: 'accepted', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing hash', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + instant: false, + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing instant', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing fee', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing feeRate', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with missing travelInfos', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + }; + + try { + assertDecode(FanoutUnspentsResponse, invalidResponse); + assert.fail('Expected decode to fail but it succeeded'); + } catch (e) { + // Expected to fail + assert.ok(e instanceof Error); + } + }); + + it('should reject response with non-string status', function () { + const invalidResponse = { + status: 123, // number instead of string + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: false, + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + + it('should reject response with non-string instantId when present', function () { + const invalidResponse = { + status: 'accepted', + tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + instant: true, + instantId: 123, // number instead of string + fee: 10000, + feeRate: 20000, + travelInfos: [], + }; + + assert.throws(() => { + assertDecode(FanoutUnspentsResponse, invalidResponse); + }); + }); + }); + + describe('PutFanoutUnspents route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PutFanoutUnspents.path, '/api/v1/wallet/:id/fanoutunspents'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PutFanoutUnspents.method, 'PUT'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PutFanoutUnspents.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PutFanoutUnspents.response[200]); + assert.ok(PutFanoutUnspents.response[400]); + }); + }); +});