Skip to content

Commit 5322280

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 74b971c commit 5322280

3 files changed

Lines changed: 155 additions & 6 deletions

File tree

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

Lines changed: 32 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,27 @@ 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(
60+
intent: PopulatedIntent | undefined,
61+
chainName: string
62+
): TransactionParams | undefined {
63+
if (!intent?.recipients?.length) {
64+
return undefined;
65+
}
66+
return {
67+
recipients: intent.recipients.map((r) => ({
68+
address: r.address.address,
69+
amount: String(r.amount.value),
70+
...(r.amount.symbol && r.amount.symbol !== chainName && { tokenName: r.amount.symbol }),
71+
})),
72+
};
73+
}
74+
5375
/**
5476
* BaseTssUtil class which different signature schemes have to extend
5577
*/
@@ -579,8 +601,15 @@ export default class BaseTssUtils<KeyShare> extends MpcUtils implements ITssUtil
579601
async recreateTxRequest(txRequestId: string, decryptedPrv: string, reqId: IRequestTracer): Promise<TxRequest> {
580602
await this.deleteSignatureShares(txRequestId, reqId);
581603
// 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 });
604+
const txRequest = await this.getTxRequest(txRequestId, reqId);
605+
// EdDSA MPCv2 re-verifies the transaction against txParams.recipients before DSG starts.
606+
// On the PA path there is no SDK-local buildParams, so derive txParams from the persisted
607+
// intent. Other TSS variants either skip recipient verification or already work without txParams.
608+
const txParams =
609+
this.wallet.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa'
610+
? txParamsFromIntent(txRequest.intent as PopulatedIntent | undefined, this.baseCoin.getChain())
611+
: undefined;
612+
return await this.signTxRequest({ txRequest, prv: decryptedPrv, reqId, txParams });
584613
}
585614

586615
/**

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

Lines changed: 21 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';
@@ -50,13 +51,15 @@ import {
5051
EddsaUnsignedTransaction,
5152
IntentOptionsForMessage,
5253
IntentOptionsForTypedData,
54+
PopulatedIntent,
5355
RequestTracer,
5456
RequestType,
5557
TokenTransferRecipientParams,
5658
TokenType,
5759
TxRequest,
5860
} from '../utils';
5961
import { postWithCodec } from '../utils/postWithCodec';
62+
import { txParamsFromIntent } from '../utils/tss/baseTSSUtils';
6063
import { EcdsaMPCv2Utils, EcdsaUtils } from '../utils/tss/ecdsa';
6164
import EddsaUtils, { EddsaMPCv2Utils } from '../utils/tss/eddsa';
6265
import { getTxRequestApiVersion, validateTxRequestApiVersion } from '../utils/txRequest';
@@ -4893,9 +4896,25 @@ export class Wallet implements IWallet {
48934896
}
48944897

48954898
try {
4899+
let txRequest: string | TxRequest = params.txPrebuild.txRequestId;
4900+
let txParams: TransactionParams | undefined = params.txPrebuild.buildParams;
4901+
4902+
// EdDSA MPCv2 re-sign path: buildParams is absent when the UI calls signAndSendTxRequest with
4903+
// only txRequestId. Derive txParams from the persisted intent so verifyTransaction receives
4904+
// the correct recipients before DSG starts. Other TSS variants are unaffected by the guard.
4905+
if (!txParams && this.multisigTypeVersion() === 'MPCv2' && this.baseCoin.getMPCAlgorithm() === 'eddsa') {
4906+
txRequest = await getTxRequest(
4907+
this.bitgo,
4908+
this.id(),
4909+
params.txPrebuild.txRequestId,
4910+
params.reqId || new RequestTracer()
4911+
);
4912+
txParams = txParamsFromIntent(txRequest.intent as PopulatedIntent | undefined, this.baseCoin.getChain());
4913+
}
4914+
48964915
return await this.tssUtils!.signTxRequest({
4897-
txRequest: params.txPrebuild.txRequestId,
4898-
txParams: params.txPrebuild.buildParams,
4916+
txRequest,
4917+
txParams,
48994918
prv: params.prv,
49004919
reqId: params.reqId || new RequestTracer(),
49014920
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)