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';