Skip to content

Commit ca8ddb4

Browse files
committed
fix(sdk-core): pass txParams on EdDSA MPCv2 re-sign and PA paths
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
1 parent 5145634 commit ca8ddb4

3 files changed

Lines changed: 152 additions & 6 deletions

File tree

modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EncryptionVersion, IRequestTracer } from '../../../api';
22
import * as openpgp from 'openpgp';
33
import { Key, readKey, SerializedKeyPair } from 'openpgp';
4-
import { IBaseCoin, KeychainsTriplet } from '../../baseCoin';
4+
import { IBaseCoin, KeychainsTriplet, TransactionParams } from '../../baseCoin';
55
import { BitGoBase } from '../../bitgoBase';
66
import { Keychain, KeyIndices, WebauthnKeyEncryptionInfo } from '../../keychain';
77
import { getTxRequest } from '../../tss';
@@ -31,6 +31,7 @@ import {
3131
IntentOptionsForMessage,
3232
IntentOptionsForTypedData,
3333
ITssUtils,
34+
PopulatedIntent,
3435
PopulatedIntentForMessageSigning,
3536
PopulatedIntentForTypedDataSigning,
3637
PrebuildTransactionWithIntentOptions,
@@ -50,6 +51,25 @@ import { getBitgoGpgPubKey } from '../opengpgUtils';
5051
import assert from 'assert';
5152
import { MessageStandardType } from '../messageTypes';
5253

54+
/**
55+
* Derives txParams from the persisted intent on a TxRequest for EdDSA MPCv2 signing paths
56+
* where no SDK-local buildParams is available (PA path and UI re-sign path).
57+
* Native coin transfers (where symbol equals the chain name) are excluded from tokenName.
58+
*/
59+
export function txParamsFromIntent(intent: unknown, chainName: string): TransactionParams | undefined {
60+
const intentRecipients = (intent as PopulatedIntent | undefined)?.recipients;
61+
if (!intentRecipients?.length) {
62+
return undefined;
63+
}
64+
return {
65+
recipients: intentRecipients.map((r) => ({
66+
address: r.address.address,
67+
amount: String(r.amount.value),
68+
...(r.amount.symbol && r.amount.symbol !== chainName && { tokenName: r.amount.symbol }),
69+
})),
70+
};
71+
}
72+
5373
/**
5474
* BaseTssUtil class which different signature schemes have to extend
5575
*/
@@ -579,8 +599,15 @@ export default class BaseTssUtils<KeyShare> extends MpcUtils implements ITssUtil
579599
async recreateTxRequest(txRequestId: string, decryptedPrv: string, reqId: IRequestTracer): Promise<TxRequest> {
580600
await this.deleteSignatureShares(txRequestId, reqId);
581601
// after delete signatures shares get the tx without them
582-
const txRequest = await getTxRequest(this.bitgo, this.wallet.id(), txRequestId, reqId);
583-
return await this.signTxRequest({ txRequest, prv: decryptedPrv, reqId });
602+
const txRequest = await this.getTxRequest(txRequestId, reqId);
603+
// EdDSA MPCv2 re-verifies the transaction against txParams.recipients before DSG starts.
604+
// On the PA path there is no SDK-local buildParams, so derive txParams from the persisted
605+
// intent. Other TSS variants either skip recipient verification or already work without txParams.
606+
const txParams =
607+
this.wallet.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa'
608+
? txParamsFromIntent(txRequest.intent, this.baseCoin.getChain())
609+
: undefined;
610+
return await this.signTxRequest({ txRequest, prv: decryptedPrv, reqId, txParams });
584611
}
585612

586613
/**

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
SignedMessage,
1717
SignedTransaction,
1818
SignedTransactionRequest,
19+
TransactionParams,
1920
TransactionPrebuild,
2021
VerifyAddressOptions,
2122
} from '../baseCoin';
@@ -57,6 +58,7 @@ import {
5758
TxRequest,
5859
} from '../utils';
5960
import { postWithCodec } from '../utils/postWithCodec';
61+
import { txParamsFromIntent } from '../utils/tss/baseTSSUtils';
6062
import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa';
6163
import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa';
6264
import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest';
@@ -4816,9 +4818,25 @@ export class Wallet implements IWallet {
48164818
}
48174819

48184820
try {
4821+
let txRequest: string | TxRequest = params.txPrebuild.txRequestId;
4822+
let txParams: TransactionParams | undefined = params.txPrebuild.buildParams;
4823+
4824+
// EdDSA MPCv2 re-sign path: buildParams is absent when the UI calls signAndSendTxRequest with
4825+
// only txRequestId. Derive txParams from the persisted intent so verifyTransaction receives
4826+
// the correct recipients before DSG starts. Other TSS variants are unaffected by the guard.
4827+
if (!txParams && this.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa') {
4828+
txRequest = await getTxRequest(
4829+
this.bitgo,
4830+
this.id(),
4831+
params.txPrebuild.txRequestId,
4832+
params.reqId || new RequestTracer()
4833+
);
4834+
txParams = txParamsFromIntent(txRequest.intent, this.baseCoin.getChain());
4835+
}
4836+
48194837
return await this.tssUtils!.signTxRequest({
4820-
txRequest: params.txPrebuild.txRequestId,
4821-
txParams: params.txPrebuild.buildParams,
4838+
txRequest,
4839+
txParams,
48224840
prv: params.prv,
48234841
reqId: params.reqId || new RequestTracer(),
48244842
apiVersion: params.apiVersion,

modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as sinon from 'sinon';
66
import * as sjcl from '@bitgo/sjcl';
77
import { DklsUtils } from '@bitgo/sdk-lib-mpc';
88

9-
import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, TxRequest } from '../../../../../src';
9+
import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, IWallet, RequestTracer, TxRequest } from '../../../../../src';
1010
import BaseTssUtils from '../../../../../src/bitgo/utils/tss/baseTSSUtils';
1111

1212
type BitgoGpgKeyPair = openpgp.SerializedKeyPair<string> & { revocationCertificate: string };
@@ -322,6 +322,107 @@ describe('Base TSS Utils', function () {
322322
});
323323
});
324324

325+
describe('recreateTxRequest', function () {
326+
function makeWallet(multisigTypeVersion: 'MPCv2' | undefined): IWallet {
327+
return {
328+
id: sinon.stub().returns('wallet-id'),
329+
multisigTypeVersion: sinon.stub().returns(multisigTypeVersion),
330+
} as unknown as IWallet;
331+
}
332+
333+
function makeCoin(mpcAlgorithm: 'eddsa' | 'ecdsa', chain = 'tsol'): IBaseCoin {
334+
const coin = {} as IBaseCoin;
335+
coin.getHashFunction = sinon.stub();
336+
coin.getMPCAlgorithm = sinon.stub().returns(mpcAlgorithm);
337+
coin.getChain = sinon.stub().returns(chain);
338+
return coin;
339+
}
340+
341+
it('derives txParams from intent for EdDSA MPCv2 wallets', async function () {
342+
const txRequestId = 'tx-req-id-1';
343+
const reqId = new RequestTracer();
344+
const txRequest = buildTxRequest({
345+
txRequestId,
346+
intent: {
347+
intentType: 'payment',
348+
recipients: [{ address: { address: 'solAddr1' }, amount: { value: '5000000', symbol: 'tsol' } }],
349+
},
350+
});
351+
352+
const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa'), makeWallet('MPCv2'));
353+
sinon.stub(utils, 'deleteSignatureShares').resolves();
354+
sinon.stub(utils, 'getTxRequest').resolves(txRequest);
355+
const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest);
356+
357+
await utils.recreateTxRequest(txRequestId, 'prv', reqId);
358+
359+
// Native SOL: symbol equals the chain name, so tokenName must be omitted
360+
assert.deepStrictEqual(signTxRequestStub.firstCall.args[0].txParams, {
361+
recipients: [{ address: 'solAddr1', amount: '5000000' }],
362+
});
363+
});
364+
365+
it('sets tokenName for SPL tokens (symbol differs from chain name)', async function () {
366+
const txRequestId = 'tx-req-id-spl';
367+
const reqId = new RequestTracer();
368+
const txRequest = buildTxRequest({
369+
txRequestId,
370+
intent: {
371+
intentType: 'payment',
372+
recipients: [{ address: { address: 'splAddr1' }, amount: { value: '1000', symbol: 'tsol:usdc' } }],
373+
},
374+
});
375+
376+
const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa', 'tsol'), makeWallet('MPCv2'));
377+
sinon.stub(utils, 'deleteSignatureShares').resolves();
378+
sinon.stub(utils, 'getTxRequest').resolves(txRequest);
379+
const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest);
380+
381+
await utils.recreateTxRequest(txRequestId, 'prv', reqId);
382+
383+
// SPL token: symbol differs from chain name, so tokenName must be set
384+
assert.deepStrictEqual(signTxRequestStub.firstCall.args[0].txParams, {
385+
recipients: [{ address: 'splAddr1', amount: '1000', tokenName: 'tsol:usdc' }],
386+
});
387+
});
388+
389+
it('passes undefined txParams for EdDSA MPCv2 when intent has no recipients', async function () {
390+
const txRequestId = 'tx-req-id-2';
391+
const reqId = new RequestTracer();
392+
const txRequest = buildTxRequest({ txRequestId, intent: { intentType: 'enableToken' } });
393+
394+
const utils = new TestBaseTssUtils(mockBitgo, makeCoin('eddsa'), makeWallet('MPCv2'));
395+
sinon.stub(utils, 'deleteSignatureShares').resolves();
396+
sinon.stub(utils, 'getTxRequest').resolves(txRequest);
397+
const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest);
398+
399+
await utils.recreateTxRequest(txRequestId, 'prv', reqId);
400+
401+
assert.strictEqual(signTxRequestStub.firstCall.args[0].txParams, undefined);
402+
});
403+
404+
it('passes undefined txParams for ECDSA MPCv2 wallets (guard does not apply)', async function () {
405+
const txRequestId = 'tx-req-id-3';
406+
const reqId = new RequestTracer();
407+
const txRequest = buildTxRequest({
408+
txRequestId,
409+
intent: {
410+
intentType: 'payment',
411+
recipients: [{ address: { address: 'ethAddr1' }, amount: { value: '1000000', symbol: 'eth' } }],
412+
},
413+
});
414+
415+
const utils = new TestBaseTssUtils(mockBitgo, makeCoin('ecdsa'), makeWallet('MPCv2'));
416+
sinon.stub(utils, 'deleteSignatureShares').resolves();
417+
sinon.stub(utils, 'getTxRequest').resolves(txRequest);
418+
const signTxRequestStub = sinon.stub(utils, 'signTxRequest').resolves(txRequest);
419+
420+
await utils.recreateTxRequest(txRequestId, 'prv', reqId);
421+
422+
assert.strictEqual(signTxRequestStub.firstCall.args[0].txParams, undefined);
423+
});
424+
});
425+
325426
describe('ECDSA MPC v2 delegated txRequest parsing', function () {
326427
it('getHashStringAndDerivationPath produces the correct hashBuffer and derivationPath', async function () {
327428
const signableHex = 'deadbeef';

0 commit comments

Comments
 (0)