diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 68b3e615a2..c50e6e133d 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -708,7 +708,7 @@ async function handleV2AcceptWalletShare(req: express.Request) { /** * handle wallet sign transaction */ -async function handleV2SignTxWallet(req: express.Request) { +async function handleV2SignTxWallet(req: ExpressApiRouteRequest<'express.v2.wallet.signtx', 'post'>) { const bitgo = req.bitgo; const coin = bitgo.coin(req.params.coin); const wallet = await coin.wallets().get({ id: req.params.id }); @@ -1633,7 +1633,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // sign transaction router.post('express.v2.coin.signtx', [prepareBitGo(config), typedPromiseWrapper(handleV2SignTx)]); - app.post('/api/v2/:coin/wallet/:id/signtx', parseBody, prepareBitGo(config), promiseWrapper(handleV2SignTxWallet)); + router.post('express.v2.wallet.signtx', [prepareBitGo(config), typedPromiseWrapper(handleV2SignTxWallet)]); app.post( '/api/v2/:coin/wallet/:id/signtxtss', parseBody, diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 2d7da518f6..9bcc631532 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -27,6 +27,7 @@ import { PutFanoutUnspents } from './v1/fanoutUnspents'; import { PostOfcSignPayload } from './v2/ofcSignPayload'; import { PostWalletRecoverToken } from './v2/walletRecoverToken'; import { PostCoinSignTx } from './v2/coinSignTx'; +import { PostWalletSignTx } from './v2/walletSignTx'; export const ExpressApi = apiSpec({ 'express.ping': { @@ -104,6 +105,9 @@ export const ExpressApi = apiSpec({ 'express.v2.coin.signtx': { post: PostCoinSignTx, }, + 'express.v2.wallet.signtx': { + post: PostWalletSignTx, + }, }); export type ExpressApi = typeof ExpressApi; diff --git a/modules/express/src/typedRoutes/api/v2/walletSignTx.ts b/modules/express/src/typedRoutes/api/v2/walletSignTx.ts new file mode 100644 index 0000000000..5cae7e1233 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/walletSignTx.ts @@ -0,0 +1,168 @@ +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 path parameters for signing a wallet transaction + */ +export const WalletSignTxParams = { + /** The coin type */ + coin: t.string, + /** The wallet ID */ + id: t.string, +} as const; + +/** + * Transaction prebuild information for wallet signing + */ +export const WalletTransactionPrebuild = 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, + /** Wallet ID for the transaction */ + walletId: t.string, + /** 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 wallet transaction + */ +export const WalletSignTxBody = { + /** Private key for signing */ + prv: optional(t.string), + /** Transaction prebuild data */ + txPrebuild: optional(WalletTransactionPrebuild), + /** Public keys for multi-signature transactions */ + pubs: optional(t.array(t.string)), + /** Transaction request ID for TSS wallets */ + txRequestId: optional(t.string), + /** Cosigner public key */ + cosignerPub: optional(t.string), + /** Whether this is the last signature in a multi-sig tx */ + isLastSignature: optional(t.boolean), + /** Wallet passphrase for TSS wallets */ + walletPassphrase: optional(t.string), + /** API version: 'lite' or 'full' */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), + /** Multisig type version */ + multisigTypeVersion: optional(t.literal('MPCv2')), + /** 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.union([t.string, t.number])), + /** 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), + /** For EVM cross-chain recovery */ + isEvmBasedCrossChainRecovery: optional(t.boolean), + /** Derivation seed for key derivation */ + derivationSeed: optional(t.string), +} 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.type({ + halfSigned: t.partial({ + txHex: optional(t.string), + payload: optional(t.string), + txBase64: optional(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 wallet transaction + * + * Uses TxRequestResponse (TransactionRequest) from @bitgo/public-types for TSS transaction requests + * (supports both Lite and Full versions) + */ +export const WalletSignTxResponse = { + /** Successfully signed transaction */ + 200: t.union([ + FullySignedTransactionResponse, + HalfSignedAccountTransactionResponse, + HalfSignedUtxoTransactionResponse, + SignedTransactionRequestResponse, + TxRequestResponse, + ]), + /** Error response */ + 400: BitgoExpressError, +}; + +/** + * Sign a transaction for a specific wallet + * + * This endpoint signs a transaction for a specific wallet identified by coin type and wallet ID. + * The request body is passed to wallet.signTransaction() and varies by coin and wallet type. + * + * Common fields include: + * - txPrebuild: Contains transaction data like txHex or txBase64 + * - prv: Private key for signing (for non-TSS wallets) + * - walletPassphrase: Passphrase for TSS wallets + * - txRequestId: Transaction request ID for TSS wallets + * - isLastSignature: Whether this is the last signature in a multi-sig tx + * - pubs: Public keys for multi-signature transactions + * - apiVersion: 'lite' or 'full' for TSS transaction requests + * - gasLimit: Gas limit for ETH transactions + * - gasPrice: Gas price for ETH transactions + * - expireTime: Transaction expiration time + * - sequenceId: Sequence ID for transactions + * - isEvmBasedCrossChainRecovery: For EVM cross-chain recovery + * + * @operationId express.v2.wallet.signtx + */ +export const PostWalletSignTx = httpRoute({ + path: '/api/v2/:coin/wallet/:id/signtx', + method: 'POST', + request: httpRequest({ + params: WalletSignTxParams, + body: WalletSignTxBody, + }), + response: WalletSignTxResponse, +}); diff --git a/modules/express/test/unit/typedRoutes/walletSignTx.ts b/modules/express/test/unit/typedRoutes/walletSignTx.ts new file mode 100644 index 0000000000..cf29792e6d --- /dev/null +++ b/modules/express/test/unit/typedRoutes/walletSignTx.ts @@ -0,0 +1,683 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; +import { + WalletSignTxParams, + WalletTransactionPrebuild, + WalletSignTxBody, + FullySignedTransactionResponse, + HalfSignedAccountTransactionResponse, + HalfSignedUtxoTransactionResponse, + SignedTransactionRequestResponse, + PostWalletSignTx, +} from '../../../src/typedRoutes/api/v2/walletSignTx'; +import { assertDecode } from './common'; + +describe('WalletSignTx codec tests', function () { + describe('WalletSignTxParams', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'btc', + id: '5a1341e7c8421dc90710673b3166bbd5', + }; + + const decoded = assertDecode(t.type(WalletSignTxParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing coin', function () { + const invalidParams = { + id: '5a1341e7c8421dc90710673b3166bbd5', + }; + + assert.throws(() => { + assertDecode(t.type(WalletSignTxParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'btc', + }; + + assert.throws(() => { + assertDecode(t.type(WalletSignTxParams), invalidParams); + }); + }); + + it('should reject params with non-string coin', function () { + const invalidParams = { + coin: 123, // number instead of string + id: '5a1341e7c8421dc90710673b3166bbd5', + }; + + assert.throws(() => { + assertDecode(t.type(WalletSignTxParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + coin: 'btc', + id: 12345, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(WalletSignTxParams), invalidParams); + }); + }); + }); + + describe('WalletTransactionPrebuild', function () { + it('should validate prebuild with all fields', function () { + const validPrebuild = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + txBase64: + 'AQAAAAFz2JT3Xvjk8jKcYcMrKR8tPMRm5+/Q6J2sMgtz7QDpAAAAAAD+////AoCWmAAAAAAAGXapFJA29QPQaHHwR3Uriuhw2A6tHkPgiKwAAAAAAAEBH9cQ2QAAAAAAAXapFCf/zr8zPrMftHGIRsOt0Cf+wdOyiKwA', + txInfo: { + inputs: [{ address: '1abc', value: 100000 }], + outputs: [{ address: '1xyz', value: 95000 }], + }, + walletId: '5a1341e7c8421dc90710673b3166bbd5', + nextContractSequenceId: 123, + isBatch: true, + eip1559: { + maxPriorityFeePerGas: '10000000000', + maxFeePerGas: '20000000000', + }, + hopTransaction: { + txHex: '0x123456', + gasPrice: '20000000000', + }, + backupKeyNonce: 42, + recipients: [ + { address: '1abc', amount: 100000 }, + { address: '1xyz', amount: 95000 }, + ], + }; + + const decoded = assertDecode(WalletTransactionPrebuild, validPrebuild); + assert.strictEqual(decoded.txHex, validPrebuild.txHex); + assert.strictEqual(decoded.txBase64, validPrebuild.txBase64); + assert.deepStrictEqual(decoded.txInfo, validPrebuild.txInfo); + assert.strictEqual(decoded.walletId, validPrebuild.walletId); + assert.strictEqual(decoded.nextContractSequenceId, validPrebuild.nextContractSequenceId); + assert.strictEqual(decoded.isBatch, validPrebuild.isBatch); + assert.deepStrictEqual(decoded.eip1559, validPrebuild.eip1559); + assert.deepStrictEqual(decoded.hopTransaction, validPrebuild.hopTransaction); + assert.strictEqual(decoded.backupKeyNonce, validPrebuild.backupKeyNonce); + assert.deepStrictEqual(decoded.recipients, validPrebuild.recipients); + }); + + it('should validate empty prebuild', function () { + const validPrebuild = {}; + const decoded = assertDecode(WalletTransactionPrebuild, validPrebuild); + assert.deepStrictEqual(decoded, {}); + }); + + it('should reject prebuild with invalid field types', function () { + const invalidPrebuild = { + txHex: 123, // number instead of string + isBatch: 'true', // string instead of boolean + nextContractSequenceId: '123', // string instead of number + }; + + assert.throws(() => { + assertDecode(WalletTransactionPrebuild, invalidPrebuild); + }); + }); + }); + + describe('WalletSignTxBody', function () { + it('should validate body with all fields', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + pubs: [ + '03a247b2c6826c3f833c6e164a3be1b124bf5f6de0d837a143a4d81e427a43a26f', + '02d3a8e9a42b89168a54f09476d40b8d60f5d553f6dcc8e5bf3e8b2733cff25c92', + ], + txRequestId: 'tx-req-5a1341e7c8421dc90710673b3166bbd5', + cosignerPub: '03b8c1f8c0e8ad9f1e64b2c4ed71b8e1cb8c8e9d8f2e6b5a7c3d9e1f4a2b6c8d', + isLastSignature: true, + walletPassphrase: 'my-secure-passphrase', + apiVersion: 'lite', + multisigTypeVersion: 'MPCv2', + gasLimit: 21000, + gasPrice: '20000000000', + expireTime: 1633046400000, + sequenceId: 42, + recipients: [ + { address: '1abc', amount: 100000 }, + { address: '1xyz', amount: 95000 }, + ], + custodianTransactionId: 'custodian-tx-123456', + signingStep: 'signerNonce', + allowNonSegwitSigningWithoutPrevTx: true, + isEvmBasedCrossChainRecovery: true, + derivationSeed: 'derivation-seed-abc123', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.prv, validBody.prv); + assert.deepStrictEqual(decoded.txPrebuild, validBody.txPrebuild); + assert.deepStrictEqual(decoded.pubs, validBody.pubs); + assert.strictEqual(decoded.txRequestId, validBody.txRequestId); + assert.strictEqual(decoded.cosignerPub, validBody.cosignerPub); + assert.strictEqual(decoded.isLastSignature, validBody.isLastSignature); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + assert.strictEqual(decoded.apiVersion, validBody.apiVersion); + assert.strictEqual(decoded.multisigTypeVersion, validBody.multisigTypeVersion); + assert.strictEqual(decoded.gasLimit, validBody.gasLimit); + assert.strictEqual(decoded.gasPrice, validBody.gasPrice); + assert.strictEqual(decoded.expireTime, validBody.expireTime); + assert.strictEqual(decoded.sequenceId, validBody.sequenceId); + assert.deepStrictEqual(decoded.recipients, validBody.recipients); + assert.strictEqual(decoded.custodianTransactionId, validBody.custodianTransactionId); + assert.strictEqual(decoded.signingStep, validBody.signingStep); + assert.strictEqual(decoded.allowNonSegwitSigningWithoutPrevTx, validBody.allowNonSegwitSigningWithoutPrevTx); + assert.strictEqual(decoded.isEvmBasedCrossChainRecovery, validBody.isEvmBasedCrossChainRecovery); + assert.strictEqual(decoded.derivationSeed, validBody.derivationSeed); + }); + + it('should validate empty body', function () { + const validBody = {}; + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.deepStrictEqual(decoded, {}); + }); + + it('should validate body with traditional wallet parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.prv, validBody.prv); + assert.deepStrictEqual(decoded.txPrebuild, validBody.txPrebuild); + }); + + it('should validate body with TSS wallet parameters', function () { + const validBody = { + walletPassphrase: 'my-secure-passphrase', + txRequestId: 'tx-req-5a1341e7c8421dc90710673b3166bbd5', + apiVersion: 'full', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + assert.strictEqual(decoded.txRequestId, validBody.txRequestId); + assert.strictEqual(decoded.apiVersion, validBody.apiVersion); + }); + + it('should validate body with multi-signature parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: '0100000001...', + }, + pubs: ['pub1', 'pub2', 'pub3'], + cosignerPub: '03b8c1f8c0e8ad9f1e64b2c4ed71b8e1cb8c8e9d8f2e6b5a7c3d9e1f4a2b6c8d', + isLastSignature: false, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.deepStrictEqual(decoded.pubs, validBody.pubs); + assert.strictEqual(decoded.cosignerPub, validBody.cosignerPub); + assert.strictEqual(decoded.isLastSignature, validBody.isLastSignature); + }); + + it('should validate body with ETH-specific parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: '0x...', + eip1559: { + maxFeePerGas: '20000000000', + }, + }, + gasLimit: '21000', + gasPrice: 20000000000, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.gasLimit, validBody.gasLimit); + assert.strictEqual(decoded.gasPrice, validBody.gasPrice); + }); + + it('should validate body with gasLimit and gasPrice as different types', function () { + const validBody1 = { + gasLimit: 21000, // as number + gasPrice: '20000000000', // as string + }; + + const decoded1 = assertDecode(t.partial(WalletSignTxBody), validBody1); + assert.strictEqual(decoded1.gasLimit, validBody1.gasLimit); + assert.strictEqual(decoded1.gasPrice, validBody1.gasPrice); + + const validBody2 = { + gasLimit: '21000', // as string + gasPrice: 20000000000, // as number + }; + + const decoded2 = assertDecode(t.partial(WalletSignTxBody), validBody2); + assert.strictEqual(decoded2.gasLimit, validBody2.gasLimit); + assert.strictEqual(decoded2.gasPrice, validBody2.gasPrice); + }); + + it('should validate body with sequenceId as different types', function () { + const validBody1 = { + sequenceId: 42, // as number + }; + + const decoded1 = assertDecode(t.partial(WalletSignTxBody), validBody1); + assert.strictEqual(decoded1.sequenceId, validBody1.sequenceId); + + const validBody2 = { + sequenceId: '42', // as string + }; + + const decoded2 = assertDecode(t.partial(WalletSignTxBody), validBody2); + assert.strictEqual(decoded2.sequenceId, validBody2.sequenceId); + }); + + it('should validate body with apiVersion lite', function () { + const validBody = { + walletPassphrase: 'my-passphrase', + apiVersion: 'lite', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.apiVersion, validBody.apiVersion); + }); + + it('should validate body with apiVersion full', function () { + const validBody = { + walletPassphrase: 'my-passphrase', + apiVersion: 'full', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.apiVersion, validBody.apiVersion); + }); + + it('should validate body with all signingStep values', function () { + const signingSteps = ['signerNonce', 'signerSignature', 'cosignerNonce']; + + signingSteps.forEach((step) => { + const validBody = { + signingStep: step, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.signingStep, validBody.signingStep); + }); + }); + + it('should validate body with custodian parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: '0100000001...', + }, + custodianTransactionId: 'custodian-abc-123456', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.custodianTransactionId, validBody.custodianTransactionId); + }); + + it('should validate body with Bitcoin-specific parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: '0100000001...', + }, + allowNonSegwitSigningWithoutPrevTx: true, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.allowNonSegwitSigningWithoutPrevTx, validBody.allowNonSegwitSigningWithoutPrevTx); + }); + + it('should validate body with EVM cross-chain recovery parameters', function () { + const validBody = { + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + txPrebuild: { + txHex: '0x...', + }, + isEvmBasedCrossChainRecovery: true, + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.isEvmBasedCrossChainRecovery, validBody.isEvmBasedCrossChainRecovery); + }); + + it('should validate body with external signer parameters', function () { + const validBody = { + txPrebuild: { + txHex: '0100000001...', + }, + derivationSeed: 'my-derivation-seed-123', + }; + + const decoded = assertDecode(t.partial(WalletSignTxBody), validBody); + assert.strictEqual(decoded.derivationSeed, validBody.derivationSeed); + }); + + it('should reject body with invalid apiVersion', function () { + const invalidBody = { + apiVersion: 'invalid', // not 'lite' or 'full' + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + + it('should reject body with invalid multisigTypeVersion', function () { + const invalidBody = { + multisigTypeVersion: 'MPCv1', // not 'MPCv2' + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + + it('should reject body with invalid field types', function () { + const invalidBody = { + prv: 123, // number instead of string + isLastSignature: 'true', // string instead of boolean + expireTime: '1633046400000', // string instead of number + pubs: 'not-an-array', // string instead of array + signingStep: 'invalidStep', // not one of the allowed values + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + + it('should reject body with invalid pubs array items', function () { + const invalidBody = { + pubs: [123, 456], // numbers instead of strings + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + + it('should reject body with invalid gasLimit type', function () { + const invalidBody = { + gasLimit: true, // boolean instead of string or number + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + + it('should reject body with invalid sequenceId type', function () { + const invalidBody = { + sequenceId: true, // boolean instead of string or number + }; + + assert.throws(() => { + assertDecode(t.partial(WalletSignTxBody), invalidBody); + }); + }); + }); + + describe('Response Types', 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 reject response with missing txHex', function () { + const invalidResponse = {}; + + assert.throws(() => { + assertDecode(FullySignedTransactionResponse, invalidResponse); + }); + }); + + it('should reject response with non-string txHex', function () { + const invalidResponse = { + txHex: 123, + }; + + assert.throws(() => { + assertDecode(FullySignedTransactionResponse, invalidResponse); + }); + }); + }); + + describe('HalfSignedAccountTransactionResponse', function () { + it('should validate response with all halfSigned fields', function () { + const validResponse = { + halfSigned: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + payload: '{"serializedTx":"0x123456","signature":"0xabcdef"}', + txBase64: + 'AQAAAAFz2JT3Xvjk8jKcYcMrKR8tPMRm5+/Q6J2sMgtz7QDpAAAAAAD+////AoCWmAAAAAAAGXapFJA29QPQaHHwR3Uriuhw2A6tHkPgiKwAAAAAAAEBH9cQ2QAAAAAAAXapFCf/zr8zPrMftHGIRsOt0Cf+wdOyiKwA', + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.txHex, validResponse.halfSigned.txHex); + assert.strictEqual(decoded.halfSigned.payload, validResponse.halfSigned.payload); + assert.strictEqual(decoded.halfSigned.txBase64, validResponse.halfSigned.txBase64); + }); + + it('should validate response with only txHex in halfSigned', function () { + const validResponse = { + halfSigned: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.txHex, validResponse.halfSigned.txHex); + }); + + it('should validate response with only payload in halfSigned', function () { + const validResponse = { + halfSigned: { + payload: '{"serializedTx":"0x123456"}', + }, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.strictEqual(decoded.halfSigned.payload, validResponse.halfSigned.payload); + }); + + it('should validate response with empty halfSigned', function () { + const validResponse = { + halfSigned: {}, + }; + + const decoded = assertDecode(HalfSignedAccountTransactionResponse, validResponse); + assert.deepStrictEqual(decoded.halfSigned, {}); + }); + + it('should reject response with missing halfSigned', function () { + const invalidResponse = {}; + + assert.throws(() => { + assertDecode(HalfSignedAccountTransactionResponse, invalidResponse); + }); + }); + }); + + describe('HalfSignedUtxoTransactionResponse', function () { + it('should validate response with required txHex', function () { + const validResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + const decoded = assertDecode(HalfSignedUtxoTransactionResponse, validResponse); + assert.strictEqual(decoded.txHex, validResponse.txHex); + }); + + it('should reject response with missing txHex', 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 reject response with missing txRequestId', function () { + const invalidResponse = {}; + + assert.throws(() => { + assertDecode(SignedTransactionRequestResponse, invalidResponse); + }); + }); + + it('should reject response with non-string txRequestId', function () { + const invalidResponse = { + txRequestId: 12345, + }; + + assert.throws(() => { + assertDecode(SignedTransactionRequestResponse, invalidResponse); + }); + }); + }); + + describe('TxRequestResponse', function () { + it('should validate response with all required fields', function () { + const validResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + walletId: '5a1341e7c8421dc90710673b3166bbd5', + version: 1, + state: 'pendingApproval', + date: '2023-01-01T00:00:00.000Z', + createdDate: '2023-01-01T00:00:00.000Z', + userId: '5a1341e7c8421dc90710673b3166bbd5', + initiatedBy: '5a1341e7c8421dc90710673b3166bbd5', + updatedBy: '5a1341e7c8421dc90710673b3166bbd5', + intents: [], + latest: true, + }; + + const decoded = assertDecode(TxRequestResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + assert.strictEqual(decoded.walletId, validResponse.walletId); + assert.strictEqual(decoded.version, validResponse.version); + assert.strictEqual(decoded.state, validResponse.state); + assert.strictEqual(decoded.userId, validResponse.userId); + assert.strictEqual(decoded.initiatedBy, validResponse.initiatedBy); + assert.strictEqual(decoded.updatedBy, validResponse.updatedBy); + assert.deepStrictEqual(decoded.intents, validResponse.intents); + assert.strictEqual(decoded.latest, validResponse.latest); + }); + + it('should validate response with optional fields for TSS wallets (Lite version)', function () { + const validResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + walletId: '5a1341e7c8421dc90710673b3166bbd5', + walletType: 'hot', + version: 1, + state: 'signed', + date: '2023-01-01T00:00:00.000Z', + createdDate: '2023-01-01T00:00:00.000Z', + userId: '5a1341e7c8421dc90710673b3166bbd5', + initiatedBy: '5a1341e7c8421dc90710673b3166bbd5', + updatedBy: '5a1341e7c8421dc90710673b3166bbd5', + intents: [], + enterpriseId: '5a1341e7c8421dc90710673b3166bbd5', + intent: {}, + pendingApprovalId: '5a1341e7c8421dc90710673b3166bbd5', + policiesChecked: true, + signatureShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + }, + ], + commitmentShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + type: 'commitment', + }, + ], + txHashes: ['hash1', 'hash2'], + unsignedTxs: [ + { + serializedTxHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + signableHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + derivationPath: "m/44'/0'/0'/0/0", + }, + ], + apiVersion: 'lite', + latest: true, + }; + + const decoded = assertDecode(TxRequestResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + assert.strictEqual(decoded.walletId, validResponse.walletId); + assert.strictEqual(decoded.version, validResponse.version); + assert.strictEqual(decoded.state, validResponse.state); + assert.strictEqual(decoded.userId, validResponse.userId); + assert.strictEqual(decoded.latest, validResponse.latest); + }); + }); + }); + + describe('PostWalletSignTx route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostWalletSignTx.path, '/api/v2/:coin/wallet/:id/signtx'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostWalletSignTx.method, 'POST'); + }); + + it('should have the correct request configuration', function () { + assert.ok(PostWalletSignTx.request); + }); + + it('should have the correct response types', function () { + assert.ok(PostWalletSignTx.response[200]); + assert.ok(PostWalletSignTx.response[400]); + }); + }); +});