From 910a4a00be453ad5d41c60932d5657915b28e4ac Mon Sep 17 00:00:00 2001 From: Dadam Rishikesh Reddy Date: Tue, 21 Apr 2026 18:52:21 +0530 Subject: [PATCH] feat(express): add Type codec for createTSSSendParams TICKET: WCI-48 --- modules/express/encryptedPrivKeys.json | 4 +- modules/express/src/clientRoutes.ts | 24 ++- .../api/common/createTSSSendParams.ts | 161 ++++++++++++++++++ .../src/typedRoutes/api/v1/verifyAddress.ts | 27 +++ .../unit/typedRoutes/createTSSSendParams.ts | 145 ++++++++++++++++ 5 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/common/createTSSSendParams.ts create mode 100644 modules/express/src/typedRoutes/api/v1/verifyAddress.ts create mode 100644 modules/express/test/unit/typedRoutes/createTSSSendParams.ts diff --git a/modules/express/encryptedPrivKeys.json b/modules/express/encryptedPrivKeys.json index fa9742e4d4..9e26dfeeb6 100644 --- a/modules/express/encryptedPrivKeys.json +++ b/modules/express/encryptedPrivKeys.json @@ -1,3 +1 @@ -{ - "61f039aad587c2000745c687373e0fa9": "{\"iv\":\"/Gnh+Ip1G+IOhy+Cms+umQ==\",\"v\":1,\"iter\":10000,\"ks\":256,\"ts\":64,\"mode\":\"ccm\",\"adata\":\"\",\"cipher\":\"aes\",\"salt\":\"FYnd1xwReTw=\",\"ct\":\"vgnCvdJ1Z9sqeV6urYxNsscwnkB/6eSPsZhzaW4Cuc7RKEY1uWNlleR0Tjtd8nlQuhsA5UXFpOID3lHHHjPDvB+jWtRm08I2F+HNGYuklWG12vIiSrY2KnkYRJkyCghn5Pq3iEimQb9M2kkwj5wf4EtfAiz9jsY=\"}" -} \ No newline at end of file +{} \ No newline at end of file diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 7b8ebdc582..4786cad6e6 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -61,6 +61,8 @@ import { isLightningCoinName } from '@bitgo/abstract-lightning'; import { handleLightningWithdraw } from './lightning/lightningWithdrawRoutes'; import createExpressRouter from './typedRoutes'; import { ExpressApiRouteRequest } from './typedRoutes/api'; +import { CreateTSSSendParamsBody } from './typedRoutes/api/common/createTSSSendParams'; +import { createValidationError } from './typedRoutes/utils'; import { TypedRequestHandler, WrappedRequest, WrappedResponse } from '@api-ts/typed-express-router'; const { version } = require('bitgo/package.json'); @@ -877,12 +879,24 @@ function createSendParams(req: express.Request) { } } -function createTSSSendParams(req: express.Request, wallet: Wallet) { +// Return type is intentionally `any` to preserve the pre-existing behaviour of +// the three call sites (wallet.sendMany / sendAccountConsolidations / +// ensureCleanSigSharesAndSignTransaction each take slightly different option +// shapes). This ticket only narrows the *input* via the io-ts codec; return +// type strengthening is tracked separately. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createTSSSendParams(req: express.Request, wallet: Wallet): any { + const decoded = CreateTSSSendParamsBody.decode(req.body); + if (decoded._tag === 'Left') { + throw createValidationError(decoded.left); + } + const body = decoded.right; + if (req.config?.externalSignerUrl !== undefined) { const coin = req.bitgo.coin(req.params.coin); if (coin.getMPCAlgorithm() === MPCType.EDDSA) { return { - ...req.body, + ...body, customCommitmentGeneratingFunction: createCustomCommitmentGenerator( req.config.externalSignerUrl, req.params.coin @@ -893,7 +907,7 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { } else if (coin.getMPCAlgorithm() === MPCType.ECDSA) { if (wallet._wallet.multisigTypeVersion === 'MPCv2') { return { - ...req.body, + ...body, customMPCv2SigningRound1GenerationFunction: createCustomMPCv2SigningRound1Generator( req.config.externalSignerUrl, req.params.coin @@ -909,7 +923,7 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { }; } else { return { - ...req.body, + ...body, customPaillierModulusGeneratingFunction: createCustomPaillierModulusGetter( req.config.externalSignerUrl, req.params.coin @@ -926,7 +940,7 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { throw new Error(`MPC Algorithm ${coin.getMPCAlgorithm()} is not supported.`); } } else { - return req.body; + return body; } } diff --git a/modules/express/src/typedRoutes/api/common/createTSSSendParams.ts b/modules/express/src/typedRoutes/api/common/createTSSSendParams.ts new file mode 100644 index 0000000000..b13dea5b5a --- /dev/null +++ b/modules/express/src/typedRoutes/api/common/createTSSSendParams.ts @@ -0,0 +1,161 @@ +/** + * @prettier + */ +import * as t from 'io-ts'; +import { EIP1559Params, MemoParams, TokenEnablement } from '../v2/sendmany'; + +/** + * Recipient entry accepted by the sendMany / sendCoins / consolidateAccount + * flows that feed `createTSSSendParams`. + */ +const Recipient = t.intersection([ + t.type({ + address: t.string, + amount: t.union([t.number, t.string]), + }), + t.partial({ + feeLimit: t.string, + tokenName: t.string, + // `data` and `tokenData` are passed through to wallet.send*; their strict + // shapes live in the route-level codecs. We accept `any` here to mirror + // that precedent and preserve existing behaviour. + data: t.any, + tokenData: t.any, + memo: t.union([t.string, MemoParams]), + }), +]); + +/** + * Request body accepted by `createTSSSendParams` in clientRoutes. + * + * The helper is called from three route handlers, all of which already + * validate their own request bodies at the route layer: + * - express.wallet.signtxtss (walletTxSignTSS) + * - express.wallet.consolidateaccount (consolidateAccount) + * - express.wallet.sendmany / express.wallet.sendcoins (sendmany / sendCoins) + * + * This codec is the helper's own contract: the union of fields any of those + * callers may place into `req.body` before it is spread into the TSS signing + * params. Every field is optional because each caller only uses a subset. + * + * Unknown fields are preserved by io-ts `t.partial` decoding, so extra + * coin-specific or forward-compatible fields flow through unchanged. + */ +export const CreateTSSSendParamsBody = t.partial({ + // Auth / signing material + walletPassphrase: t.string, + xprv: t.string, + prv: t.string, + pubs: t.array(t.string), + cosignerPub: t.string, + isLastSignature: t.boolean, + otp: t.string, + derivationSeed: t.string, + + // TSS / transaction request / prebuild + txRequestId: t.string, + // `txPrebuild` / `prebuildTx` / verification blobs are passed through to the + // wallet SDK, which owns their strict shapes. Mirrors the route-level + // codecs (see sendmany / consolidateAccount) which also use `t.any`. + txPrebuild: t.any, + prebuildTx: t.union([t.string, t.any]), + apiVersion: t.string, + multisigTypeVersion: t.literal('MPCv2'), + signingStep: t.union([t.literal('signerNonce'), t.literal('signerSignature'), t.literal('cosignerNonce')]), + + // Recipients / addresses / amounts + recipients: t.array(Recipient), + address: t.string, + amount: t.union([t.number, t.string]), + messages: t.array( + t.type({ + address: t.string, + message: t.string, + }) + ), + senderAddress: t.string, + senderWalletId: t.string, + receiveAddress: t.string, + changeAddress: t.string, + closeRemainderTo: t.string, + consolidateAddresses: t.array(t.string), + + // Fees / gas + feeRate: t.union([t.number, t.string]), + feeLimit: t.string, + feeMultiplier: t.number, + maxFeeRate: t.number, + numBlocks: t.number, + gasLimit: t.union([t.string, t.number]), + gasPrice: t.union([t.string, t.number]), + eip1559: EIP1559Params, + + // Unspents / confirmation policy + minConfirms: t.number, + enforceMinConfirmsForChange: t.boolean, + targetWalletUnspents: t.number, + minValue: t.union([t.number, t.string]), + maxValue: t.union([t.number, t.string]), + noSplitChange: t.boolean, + unspents: t.array(t.string), + allowExternalChangeAddress: t.boolean, + allowNonSegwitSigningWithoutPrevTx: t.boolean, + + // Metadata / tracking + sequenceId: t.union([t.string, t.number]), + comment: t.string, + message: t.string, + memo: MemoParams, + transferId: t.number, + custodianTransactionId: t.string, + tokenName: t.string, + type: t.string, + addressType: t.string, + changeAddressType: t.string, + txFormat: t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')]), + keepAlive: t.boolean, + instant: t.boolean, + hop: t.boolean, + isTss: t.boolean, + isTestTransaction: t.boolean, + isEvmBasedCrossChainRecovery: t.boolean, + preview: t.boolean, + offlineVerification: t.boolean, + data: t.string, + expireTime: t.number, + + // Ledger / block validity windows + lastLedgerSequence: t.number, + ledgerSequenceDelta: t.number, + validFromBlock: t.number, + validToBlock: t.number, + + // Token / NFT / enablement + nftCollectionId: t.string, + nftId: t.string, + enableTokens: t.array(TokenEnablement), + + // Misc account-based / enterprise fields + nonce: t.string, + nonParticipation: t.boolean, + walletContractAddress: t.string, + idfSignedTimestamp: t.string, + idfUserId: t.string, + idfVersion: t.number, + lowFeeTxid: t.string, + reservation: t.partial({ + expireTime: t.string, + pendingApprovalId: t.string, + }), + + // Verification + verifyTxParams: t.any, + verification: t.any, + + // Chain-specific opaque blobs (validated at the route layer when present) + solInstructions: t.any, + solVersionedTransactionData: t.any, + aptosCustomTransactionParams: t.any, +}); + +export type CreateTSSSendParamsBody = t.TypeOf; diff --git a/modules/express/src/typedRoutes/api/v1/verifyAddress.ts b/modules/express/src/typedRoutes/api/v1/verifyAddress.ts new file mode 100644 index 0000000000..0a28991346 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v1/verifyAddress.ts @@ -0,0 +1,27 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +export const VerifyAddressBody = { + address: t.string, +}; + +/** + * Verify Address (v1) + * + * @operationId express.v1.verifyaddress + * @tag express + */ +export const PostV1VerifyAddress = httpRoute({ + path: '/api/v1/verifyaddress', + method: 'POST', + request: httpRequest({ + body: VerifyAddressBody, + }), + response: { + 200: t.type({ + verified: t.boolean, + }), + 404: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/createTSSSendParams.ts b/modules/express/test/unit/typedRoutes/createTSSSendParams.ts new file mode 100644 index 0000000000..eed37fca9c --- /dev/null +++ b/modules/express/test/unit/typedRoutes/createTSSSendParams.ts @@ -0,0 +1,145 @@ +import * as assert from 'assert'; +import { CreateTSSSendParamsBody } from '../../../src/typedRoutes/api/common/createTSSSendParams'; +import { assertDecode } from './common'; +import 'should'; + +describe('CreateTSSSendParamsBody codec', function () { + it('should accept an empty body (all fields optional)', function () { + const decoded = assertDecode(CreateTSSSendParamsBody, {}); + assert.deepStrictEqual(decoded, {}); + }); + + it('should preserve unknown fields passed through', function () { + const body = { walletPassphrase: 'pw', someUnknownField: 'hello' }; + const decoded = assertDecode(CreateTSSSendParamsBody, body) as Record; + assert.strictEqual(decoded.walletPassphrase, 'pw'); + assert.strictEqual(decoded.someUnknownField, 'hello'); + }); + + describe('walletTxSignTSS-style bodies', function () { + it('should accept a minimal TSS sign-tx body', function () { + const body = { + walletPassphrase: 'pw', + txRequestId: 'req-123', + apiVersion: 'full', + }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.strictEqual(decoded.walletPassphrase, 'pw'); + assert.strictEqual(decoded.txRequestId, 'req-123'); + assert.strictEqual(decoded.apiVersion, 'full'); + }); + + it('should accept MPCv2 multisigTypeVersion', function () { + const body = { multisigTypeVersion: 'MPCv2' as const }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.strictEqual(decoded.multisigTypeVersion, 'MPCv2'); + }); + + it('should reject non-MPCv2 multisigTypeVersion', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { multisigTypeVersion: 'MPCv1' })); + }); + + it('should accept signingStep enum values', function () { + for (const step of ['signerNonce', 'signerSignature', 'cosignerNonce']) { + const decoded = assertDecode(CreateTSSSendParamsBody, { signingStep: step }); + assert.strictEqual(decoded.signingStep, step); + } + }); + + it('should reject an invalid signingStep', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { signingStep: 'notAStep' })); + }); + }); + + describe('consolidateAccount-style bodies', function () { + it('should accept a minimal consolidation body', function () { + const body = { + walletPassphrase: 'pw', + consolidateAddresses: ['addr1', 'addr2'], + type: 'consolidate', + }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.deepStrictEqual(decoded.consolidateAddresses, ['addr1', 'addr2']); + assert.strictEqual(decoded.type, 'consolidate'); + }); + + it('should reject consolidateAddresses when not an array', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { consolidateAddresses: 'addr1' })); + }); + }); + + describe('sendMany-style bodies', function () { + it('should accept a body with recipients', function () { + const body = { + walletPassphrase: 'pw', + recipients: [ + { address: 'addr1', amount: '100' }, + { address: 'addr2', amount: 200 }, + ], + comment: 'batch payout', + sequenceId: 'abc-123', + }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.strictEqual(decoded.recipients?.length, 2); + assert.strictEqual(decoded.recipients?.[0].address, 'addr1'); + assert.strictEqual(decoded.comment, 'batch payout'); + }); + + it('should accept numeric sequenceId', function () { + const decoded = assertDecode(CreateTSSSendParamsBody, { sequenceId: 42 }); + assert.strictEqual(decoded.sequenceId, 42); + }); + + it('should reject a recipient missing address', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { recipients: [{ amount: '100' }] })); + }); + + it('should reject a recipient missing amount', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { recipients: [{ address: 'addr1' }] })); + }); + + it('should accept eip1559 fee params', function () { + const body = { + eip1559: { maxFeePerGas: '1000', maxPriorityFeePerGas: '100' }, + }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.strictEqual(decoded.eip1559?.maxFeePerGas, '1000'); + }); + }); + + describe('sendCoins-style bodies', function () { + it('should accept a single address/amount body', function () { + const body = { + walletPassphrase: 'pw', + address: 'addr1', + amount: '12345', + }; + const decoded = assertDecode(CreateTSSSendParamsBody, body); + assert.strictEqual(decoded.address, 'addr1'); + assert.strictEqual(decoded.amount, '12345'); + }); + + it('should accept a numeric amount', function () { + const decoded = assertDecode(CreateTSSSendParamsBody, { amount: 12345 }); + assert.strictEqual(decoded.amount, 12345); + }); + }); + + describe('type rejections', function () { + it('should reject non-string walletPassphrase', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { walletPassphrase: 123 })); + }); + + it('should reject non-boolean isLastSignature', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { isLastSignature: 'true' })); + }); + + it('should reject non-array pubs', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { pubs: 'pub1' })); + }); + + it('should reject invalid txFormat literal', function () { + assert.throws(() => assertDecode(CreateTSSSendParamsBody, { txFormat: 'segwit' })); + }); + }); +});