From 11e1a85b73583e3a2df089b50973ab490ac5d489 Mon Sep 17 00:00:00 2001 From: Bhuvan R Date: Mon, 18 May 2026 13:51:26 +0530 Subject: [PATCH] fix: txHex needs to be set for the txn in case of TSS TICKET: CHALO-349 --- modules/sdk-core/src/bitgo/wallet/wallet.ts | 16 ++ .../unit/bitgo/wallet/resourceManagement.ts | 139 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d05427cff5..cd2e5a1a16 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3526,6 +3526,22 @@ export class Wallet implements IWallet { delete prebuild.wallet; delete prebuild.buildParams; + // For TSS wallets the build endpoint returns only { txRequestId, stakingParams } — no txHex. + // Fetch the full txRequest to obtain serializedTxHex and populate txHex so that + // verifyTransaction (called inside prebuildAndSignTransaction) has the transaction bytes + // it needs. This mirrors what prebuildTransactionTxRequests does for other tx types. + if (this._wallet.multisigType === 'tss' && !prebuild.txHex && prebuild.txRequestId) { + const txRequest = await getTxRequest(this.bitgo, this.id(), prebuild.txRequestId, params.reqId); + const unsignedTx = + txRequest.apiVersion === 'full' ? txRequest.transactions?.[0]?.unsignedTx : txRequest.unsignedTxs?.[0]; + if (!unsignedTx?.serializedTxHex) { + throw new Error( + `Expected serializedTxHex on TSS resource management prebuild for txRequestId ${prebuild.txRequestId}` + ); + } + prebuild = _.extend({}, prebuild, { txHex: unsignedTx.serializedTxHex }); + } + prebuild = _.extend({}, prebuild, { walletId: this.id() }); debug('final resource management transaction prebuild: %O', prebuild); prebuilds.push(prebuild); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts b/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts index 01b2418ac3..85b929f06a 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts @@ -113,6 +113,145 @@ describe('Wallet - resource management', function () { bodyArg.should.have.property('delegations'); bodyArg.should.not.have.property('walletPassphrase'); }); + + describe('TSS wallet — txHex population from full txRequest', function () { + function stubTxRequestFetch(txRequest: any) { + const resultStub = sinon.stub().resolves({ txRequests: [txRequest] }); + const retryStub = sinon.stub().returns({ result: resultStub }); + const queryStub = sinon.stub().returns({ retry: retryStub }); + mockBitGo.get = sinon.stub().returns({ query: queryStub }); + mockBitGo.url = sinon.stub().returns('/mock-api/v2/wallet/test-wallet-id/txrequests'); + return { resultStub }; + } + + beforeEach(function () { + mockWalletData = { + id: 'test-wallet-id', + keys: ['user-key', 'backup-key', 'bitgo-key'], + type: 'hot', + multisigType: 'tss', + }; + wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData); + }); + + it('should fetch full txRequest and map serializedTxHex to txHex when apiVersion is full', async function () { + stubPost({ transactions: [{ txRequestId: 'req-1', stakingParams: {} }], errors: [] }); + stubTxRequestFetch({ + txRequestId: 'req-1', + apiVersion: 'full', + transactions: [{ unsignedTx: { serializedTxHex: 'serialized-hex-aaa' } }], + }); + + const result = await wallet.buildResourceDelegations({ delegations: [delegations[0]] }); + + result.prebuilds.should.have.length(1); + result.prebuilds[0]!.txHex!.should.equal('serialized-hex-aaa'); + sinon.assert.calledOnce(mockBitGo.get); + }); + + it('should fetch full txRequest and map serializedTxHex to txHex when apiVersion is lite', async function () { + stubPost({ transactions: [{ txRequestId: 'req-2', stakingParams: {} }], errors: [] }); + stubTxRequestFetch({ + txRequestId: 'req-2', + apiVersion: 'lite', + unsignedTxs: [{ serializedTxHex: 'serialized-hex-bbb' }], + }); + + const result = await wallet.buildResourceDelegations({ delegations: [delegations[0]] }); + + result.prebuilds.should.have.length(1); + result.prebuilds[0]!.txHex!.should.equal('serialized-hex-bbb'); + }); + + it('should throw when txRequest has no serializedTxHex', async function () { + stubPost({ transactions: [{ txRequestId: 'req-3', stakingParams: {} }], errors: [] }); + stubTxRequestFetch({ + txRequestId: 'req-3', + apiVersion: 'full', + transactions: [{ unsignedTx: {} }], + }); + + await (wallet.buildResourceDelegations({ delegations: [delegations[0]] }) as any).should.be.rejectedWith( + /Expected serializedTxHex/ + ); + }); + + it('should NOT fetch txRequest when txHex is already present in the build response', async function () { + stubPost({ transactions: [{ txRequestId: 'req-4', txHex: 'already-present' }], errors: [] }); + mockBitGo.get = sinon.stub(); + + const result = await wallet.buildResourceDelegations({ delegations: [delegations[0]] }); + + result.prebuilds[0]!.txHex!.should.equal('already-present'); + sinon.assert.notCalled(mockBitGo.get); + }); + + it('should NOT fetch txRequest when build response has no txRequestId', async function () { + stubPost({ transactions: [{ stakingParams: {} }], errors: [] }); + mockBitGo.get = sinon.stub(); + + await wallet.buildResourceDelegations({ delegations: [delegations[0]] }); + + sinon.assert.notCalled(mockBitGo.get); + }); + + it('should fetch txRequest once per delegation for bulk delegations', async function () { + stubPost({ + transactions: [ + { txRequestId: 'req-bulk-1', stakingParams: {} }, + { txRequestId: 'req-bulk-2', stakingParams: {} }, + ], + errors: [], + }); + const resultStub = sinon + .stub() + .onFirstCall() + .resolves({ + txRequests: [ + { + txRequestId: 'req-bulk-1', + apiVersion: 'full', + transactions: [{ unsignedTx: { serializedTxHex: 'hex-bulk-1' } }], + }, + ], + }) + .onSecondCall() + .resolves({ + txRequests: [ + { + txRequestId: 'req-bulk-2', + apiVersion: 'full', + transactions: [{ unsignedTx: { serializedTxHex: 'hex-bulk-2' } }], + }, + ], + }); + const retryStub = sinon.stub().returns({ result: resultStub }); + const queryStub = sinon.stub().returns({ retry: retryStub }); + mockBitGo.get = sinon.stub().returns({ query: queryStub }); + mockBitGo.url = sinon.stub().returns('/mock-api/v2/wallet/test-wallet-id/txrequests'); + + const result = await wallet.buildResourceDelegations({ delegations }); + + result.prebuilds.should.have.length(2); + result.prebuilds[0]!.txHex!.should.equal('hex-bulk-1'); + result.prebuilds[1]!.txHex!.should.equal('hex-bulk-2'); + sinon.assert.calledTwice(mockBitGo.get); + }); + }); + }); + + // --------------------------------------------------------------------------- + // buildResourceDelegations — non-TSS wallet, no txRequest fetch + // --------------------------------------------------------------------------- + describe('buildResourceDelegations non-TSS wallet — no txRequest fetch', function () { + it('should NOT call getTxRequest for non-TSS wallet even when txRequestId is present', async function () { + stubPost({ transactions: [{ txRequestId: 'req-hot', stakingParams: {} }], errors: [] }); + mockBitGo.get = sinon.stub(); + + await wallet.buildResourceDelegations({ delegations: [delegations[0]] }); + + sinon.assert.notCalled(mockBitGo.get); + }); }); // ---------------------------------------------------------------------------