From ca8ddb4aed2aaf6fa10788b9d45afa8332879632 Mon Sep 17 00:00:00 2001 From: Marzooqa Kather Date: Thu, 18 Jun 2026 11:21:17 +0000 Subject: [PATCH] fix(sdk-core): pass txParams on EdDSA MPCv2 re-sign and PA paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EdDSA MPCv2 verifyTransaction() compares txParams.recipients to parsed tx outputs before MPC signing. On re-sign and pending-approval flows txParams was never derived, causing a "Number of tx outputs does not match number of txParams recipients" error. Add txParamsFromIntent() which maps the persisted IntentRecipient shape on txRequest.intent into the flat ITransactionRecipient shape expected by verifyTransaction callers. Wire it in two places: - recreateTxRequest: derive txParams after fetching the fresh txRequest so PA approve → auto-sign completes correctly. - signTransactionTss: when buildParams is absent (re-sign via signAndSendTxRequest), fetch the txRequest and derive txParams so UI re-sign completes correctly. Existing buildParams / sendMany paths are unchanged; the new derivation only runs when buildParams is not already present. Ticket: WCI-765 Session-Id: 1c44b40e-24c1-49b1-b454-86318c9a44a2 Task-Id: bad80f2c-73b6-4826-a6ee-49cc4344544c --- .../src/bitgo/utils/tss/baseTSSUtils.ts | 33 +++++- modules/sdk-core/src/bitgo/wallet/wallet.ts | 22 +++- .../test/unit/bitgo/utils/tss/baseTSSUtils.ts | 103 +++++++++++++++++- 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index 1d86001223..fd8308d9b4 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -1,7 +1,7 @@ import { EncryptionVersion, IRequestTracer } from '../../../api'; import * as openpgp from 'openpgp'; import { Key, readKey, SerializedKeyPair } from 'openpgp'; -import { IBaseCoin, KeychainsTriplet } from '../../baseCoin'; +import { IBaseCoin, KeychainsTriplet, TransactionParams } from '../../baseCoin'; import { BitGoBase } from '../../bitgoBase'; import { Keychain, KeyIndices, WebauthnKeyEncryptionInfo } from '../../keychain'; import { getTxRequest } from '../../tss'; @@ -31,6 +31,7 @@ import { IntentOptionsForMessage, IntentOptionsForTypedData, ITssUtils, + PopulatedIntent, PopulatedIntentForMessageSigning, PopulatedIntentForTypedDataSigning, PrebuildTransactionWithIntentOptions, @@ -50,6 +51,25 @@ import { getBitgoGpgPubKey } from '../opengpgUtils'; import assert from 'assert'; import { MessageStandardType } from '../messageTypes'; +/** + * Derives txParams from the persisted intent on a TxRequest for EdDSA MPCv2 signing paths + * where no SDK-local buildParams is available (PA path and UI re-sign path). + * Native coin transfers (where symbol equals the chain name) are excluded from tokenName. + */ +export function txParamsFromIntent(intent: unknown, chainName: string): TransactionParams | undefined { + const intentRecipients = (intent as PopulatedIntent | undefined)?.recipients; + if (!intentRecipients?.length) { + return undefined; + } + return { + recipients: intentRecipients.map((r) => ({ + address: r.address.address, + amount: String(r.amount.value), + ...(r.amount.symbol && r.amount.symbol !== chainName && { tokenName: r.amount.symbol }), + })), + }; +} + /** * BaseTssUtil class which different signature schemes have to extend */ @@ -579,8 +599,15 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil async recreateTxRequest(txRequestId: string, decryptedPrv: string, reqId: IRequestTracer): Promise { await this.deleteSignatureShares(txRequestId, reqId); // after delete signatures shares get the tx without them - const txRequest = await getTxRequest(this.bitgo, this.wallet.id(), txRequestId, reqId); - return await this.signTxRequest({ txRequest, prv: decryptedPrv, reqId }); + const txRequest = await this.getTxRequest(txRequestId, reqId); + // EdDSA MPCv2 re-verifies the transaction against txParams.recipients before DSG starts. + // On the PA path there is no SDK-local buildParams, so derive txParams from the persisted + // intent. Other TSS variants either skip recipient verification or already work without txParams. + const txParams = + this.wallet.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa' + ? txParamsFromIntent(txRequest.intent, this.baseCoin.getChain()) + : undefined; + return await this.signTxRequest({ txRequest, prv: decryptedPrv, reqId, txParams }); } /** diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8909f33e34..605f2ab253 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -16,6 +16,7 @@ import { SignedMessage, SignedTransaction, SignedTransactionRequest, + TransactionParams, TransactionPrebuild, VerifyAddressOptions, } from '../baseCoin'; @@ -57,6 +58,7 @@ import { TxRequest, } from '../utils'; import { postWithCodec } from '../utils/postWithCodec'; +import { txParamsFromIntent } from '../utils/tss/baseTSSUtils'; import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa'; import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa'; import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest'; @@ -4816,9 +4818,25 @@ export class Wallet implements IWallet { } try { + let txRequest: string | TxRequest = params.txPrebuild.txRequestId; + let txParams: TransactionParams | undefined = params.txPrebuild.buildParams; + + // EdDSA MPCv2 re-sign path: buildParams is absent when the UI calls signAndSendTxRequest with + // only txRequestId. Derive txParams from the persisted intent so verifyTransaction receives + // the correct recipients before DSG starts. Other TSS variants are unaffected by the guard. + if (!txParams && this.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa') { + txRequest = await getTxRequest( + this.bitgo, + this.id(), + params.txPrebuild.txRequestId, + params.reqId || new RequestTracer() + ); + txParams = txParamsFromIntent(txRequest.intent, this.baseCoin.getChain()); + } + return await this.tssUtils!.signTxRequest({ - txRequest: params.txPrebuild.txRequestId, - txParams: params.txPrebuild.buildParams, + txRequest, + txParams, prv: params.prv, reqId: params.reqId || new RequestTracer(), apiVersion: params.apiVersion, diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts index 91599df991..3798523124 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts @@ -6,7 +6,7 @@ import * as sinon from 'sinon'; import * as sjcl from '@bitgo/sjcl'; import { DklsUtils } from '@bitgo/sdk-lib-mpc'; -import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, TxRequest } from '../../../../../src'; +import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, IWallet, RequestTracer, TxRequest } from '../../../../../src'; import BaseTssUtils from '../../../../../src/bitgo/utils/tss/baseTSSUtils'; type BitgoGpgKeyPair = openpgp.SerializedKeyPair & { revocationCertificate: string }; @@ -322,6 +322,107 @@ describe('Base TSS Utils', function () { }); }); + describe('recreateTxRequest', function () { + function makeWallet(multisigTypeVersion: 'MPCv2' | undefined): IWallet { + return { + id: sinon.stub().returns('wallet-id'), + multisigTypeVersion: sinon.stub().returns(multisigTypeVersion), + } as unknown as IWallet; + } + + function makeCoin(mpcAlgorithm: 'eddsa' | 'ecdsa', chain = 'tsol'): IBaseCoin { + const coin = {} as IBaseCoin; + coin.getHashFunction = sinon.stub(); + coin.getMPCAlgorithm = sinon.stub().returns(mpcAlgorithm); + coin.getChain = sinon.stub().returns(chain); + return coin; + } + + it('derives txParams from intent for EdDSA MPCv2 wallets', async function () { + const txRequestId = 'tx-req-id-1'; + const reqId = new RequestTracer(); + const txRequest = buildTxRequest({ + txRequestId, + intent: { + intentType: 'payment', + recipients: [{ address: { address: 'solAddr1' }, amount: { value: '5000000', symbol: 'tsol' } }], + }, + }); + + const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa'), makeWallet('MPCv2')); + sinon.stub(utils, 'deleteSignatureShares').resolves(); + sinon.stub(utils, 'getTxRequest').resolves(txRequest); + const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest); + + await utils.recreateTxRequest(txRequestId, 'prv', reqId); + + // Native SOL: symbol equals the chain name, so tokenName must be omitted + assert.deepStrictEqual(signTxRequestStub.firstCall.args[0].txParams, { + recipients: [{ address: 'solAddr1', amount: '5000000' }], + }); + }); + + it('sets tokenName for SPL tokens (symbol differs from chain name)', async function () { + const txRequestId = 'tx-req-id-spl'; + const reqId = new RequestTracer(); + const txRequest = buildTxRequest({ + txRequestId, + intent: { + intentType: 'payment', + recipients: [{ address: { address: 'splAddr1' }, amount: { value: '1000', symbol: 'tsol:usdc' } }], + }, + }); + + const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa', 'tsol'), makeWallet('MPCv2')); + sinon.stub(utils, 'deleteSignatureShares').resolves(); + sinon.stub(utils, 'getTxRequest').resolves(txRequest); + const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest); + + await utils.recreateTxRequest(txRequestId, 'prv', reqId); + + // SPL token: symbol differs from chain name, so tokenName must be set + assert.deepStrictEqual(signTxRequestStub.firstCall.args[0].txParams, { + recipients: [{ address: 'splAddr1', amount: '1000', tokenName: 'tsol:usdc' }], + }); + }); + + it('passes undefined txParams for EdDSA MPCv2 when intent has no recipients', async function () { + const txRequestId = 'tx-req-id-2'; + const reqId = new RequestTracer(); + const txRequest = buildTxRequest({ txRequestId, intent: { intentType: 'enableToken' } }); + + const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa'), makeWallet('MPCv2')); + sinon.stub(utils, 'deleteSignatureShares').resolves(); + sinon.stub(utils, 'getTxRequest').resolves(txRequest); + const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest); + + await utils.recreateTxRequest(txRequestId, 'prv', reqId); + + assert.strictEqual(signTxRequestStub.firstCall.args[0].txParams, undefined); + }); + + it('passes undefined txParams for ECDSA MPCv2 wallets (guard does not apply)', async function () { + const txRequestId = 'tx-req-id-3'; + const reqId = new RequestTracer(); + const txRequest = buildTxRequest({ + txRequestId, + intent: { + intentType: 'payment', + recipients: [{ address: { address: 'ethAddr1' }, amount: { value: '1000000', symbol: 'eth' } }], + }, + }); + + const utils = new TestBaseTssUtils(mockBitgo, makeCoin('ecdsa'), makeWallet('MPCv2')); + sinon.stub(utils, 'deleteSignatureShares').resolves(); + sinon.stub(utils, 'getTxRequest').resolves(txRequest); + const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest); + + await utils.recreateTxRequest(txRequestId, 'prv', reqId); + + assert.strictEqual(signTxRequestStub.firstCall.args[0].txParams, undefined); + }); + }); + describe('ECDSA MPC v2 delegated txRequest parsing', function () { it('getHashStringAndDerivationPath produces the correct hashBuffer and derivationPath', async function () { const signableHex = 'deadbeef';