From 045afe60ba9b70ccc4cf50fe8ce0b5172b9c597a Mon Sep 17 00:00:00 2001 From: Michael McShinsky Date: Thu, 30 Apr 2026 13:16:51 -0600 Subject: [PATCH 1/2] feat(sdk-core): add explicit recipient mode typing for TSS signTxRequest Introduce TssTxRecipientSource and TssSignTxRequestParams so callers can opt into compile-time enforcement of non-empty txParams.recipients via recipientSource Explicit. Default resolved behavior matches existing optional txParams. ECDSA, MPCv2, and EDDSA signing all validate Explicit at runtime for non-TS callers. ITssUtils.signTxRequest uses the new param type. Add MPCv2 positive and negative unit tests for Explicit. BREAKING CHANGE: ITssUtils.signTxRequest is now typed as TssSignTxRequestParamsWithPrv instead of a minimal inline shape. TypeScript consumers that implement or narrow this interface may need signature updates; runtime behavior for existing callers is unchanged. Refs: WAL-375 #8462 WAL-375 --- .../tssUtils/ecdsaMPCv2/signTxRequest.ts | 43 +++++++++++ .../src/bitgo/utils/tss/baseTSSUtils.ts | 4 +- .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 72 +++++++++++++++---- .../src/bitgo/utils/tss/ecdsa/ecdsa.ts | 17 ++++- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 17 ++++- .../src/bitgo/utils/tss/eddsa/eddsa.ts | 15 +++- 6 files changed, 151 insertions(+), 17 deletions(-) diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts index 678302eafb..e48b839ee5 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts @@ -7,6 +7,7 @@ import { RequestTracer, SignatureShareRecord, SignatureShareType, + TssTxRecipientSource, TxRequest, Wallet, } from '@bitgo/sdk-core'; @@ -199,6 +200,48 @@ describe('signTxRequest:', function () { nockPromises[2].isDone().should.be.true(); }); + it('successfully signs when recipientSource is explicit and txParams.recipients is non-empty', async function () { + const nockPromises = [ + await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey), + await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey), + await nockTxRequestResponseSignatureShareRoundThree(txRequest), + await nockSendTxRequest(txRequest), + ]; + await Promise.all(nockPromises); + + const userShare = fs.readFileSync(shareFiles[vector.party1]); + const userPrvBase64 = Buffer.from(userShare).toString('base64'); + await tssUtils.signTxRequest({ + txRequest, + prv: userPrvBase64, + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { + recipients: [{ address: '0x0000000000000000000000000000000000000001', amount: '1' }], + }, + }); + nockPromises[0].isDone().should.be.true(); + nockPromises[1].isDone().should.be.true(); + nockPromises[2].isDone().should.be.true(); + }); + + it('rejects when recipientSource is explicit and txParams.recipients is empty', async function () { + const userShare = fs.readFileSync(shareFiles[vector.party1]); + const userPrvBase64 = Buffer.from(userShare).toString('base64'); + // Cast bypasses the compile-time non-empty recipients constraint to exercise the runtime guard. + await tssUtils + .signTxRequest({ + txRequest, + prv: userPrvBase64, + reqId, + recipientSource: TssTxRecipientSource.Explicit, + txParams: { recipients: [] }, + } as any) + .should.be.rejectedWith( + 'recipientSource "explicit" requires txParams.recipients with at least one recipient.' + ); + }); + it('successfully signs a txRequest with backup key for a dkls hot wallet with WP', async function () { const nockPromises = [ await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey, 1), diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index 7328670068..199a96b568 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -35,7 +35,7 @@ import { SignatureShareRecord, TSSParams, TSSParamsForMessage, - TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, TxRequest, TxRequestVersion, } from './baseTypes'; @@ -221,7 +221,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil throw new Error('Method not implemented.'); } - signTxRequest(params: TSSParamsWithPrv): Promise { + signTxRequest(params: TssSignTxRequestParamsWithPrv): Promise { throw new Error('Method not implemented.'); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index d2d637761d..141835fb9b 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -1,6 +1,6 @@ import { Key, SerializedKeyPair } from 'openpgp'; import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../../api'; -import { KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin'; +import { type ITransactionRecipient, KeychainsTriplet, ParsedTransaction, TransactionParams } from '../../baseCoin'; import { ApiKeyShare, Keychain } from '../../keychain'; import { ApiVersion, Memo, WalletType } from '../../wallet'; import { EDDSA, GShare, Signature, SignShare } from '../../../account-lib/mpc/tss'; @@ -545,16 +545,6 @@ export interface EncryptedSignerShareRecord extends ShareBaseRecord { type: EncryptedSignerShareType; } -export type TSSParamsWithPrv = TSSParams & { - prv: string; - mpcv2PartyId?: 0 | 1; -}; - -export type TSSParamsForMessageWithPrv = TSSParamsForMessage & { - prv: string; - mpcv2PartyId?: 0 | 1; -}; - export type BitgoPubKeyType = 'nitro' | 'onprem'; export type TSSParams = { @@ -570,6 +560,64 @@ export type TSSParamsForMessage = TSSParams & { bufferToSign: Buffer; }; +/** At least one recipient (when using `recipientSource: TssTxRecipientSource.Explicit`). */ +export type NonEmptyRecipientList = [ITransactionRecipient, ...ITransactionRecipient[]]; + +/** txParams including a non-empty recipients list for strict signing verification typing. */ +export type TransactionParamsWithMandatoryRecipients = TransactionParams & { + recipients: NonEmptyRecipientList; +}; + +export const TssTxRecipientSource = { + /** Require txParams.recipients with at least one entry (enforced by TypeScript for this branch). */ + Explicit: 'explicit', + /** + * Default: txParams may be omitted or partial; verification uses coin-specific rules + * (for example recipients from txRequest context). + */ + Resolved: 'resolved', +} as const; + +export type TssTxRecipientSource = (typeof TssTxRecipientSource)[keyof typeof TssTxRecipientSource]; + +export type TssSignTxExplicitRecipientParams = { + txRequest: string | TxRequest; + reqId: IRequestTracer; + apiVersion?: ApiVersion; + recipientSource: typeof TssTxRecipientSource.Explicit; + txParams: TransactionParamsWithMandatoryRecipients; +}; + +export type TssSignTxResolvedRecipientParams = { + txRequest: string | TxRequest; + reqId: IRequestTracer; + apiVersion?: ApiVersion; + recipientSource?: typeof TssTxRecipientSource.Resolved; + txParams?: TransactionParams; +}; + +/** + * Parameters for TSS transaction signing ({@link ITssUtils.signTxRequest}). + * Set {@link TssTxRecipientSource.Explicit} to require a non-empty txParams.recipients array at compile time. + */ +export type TssSignTxRequestParams = TssSignTxExplicitRecipientParams | TssSignTxResolvedRecipientParams; + +export type TssSignTxRequestParamsWithPrv = TssSignTxRequestParams & { + prv: string; + mpcv2PartyId?: 0 | 1; +}; + +/** + * @deprecated Use {@link TssSignTxRequestParamsWithPrv} instead. This alias exists for + * backwards compatibility and will be removed in a future major release. + */ +export type TSSParamsWithPrv = TssSignTxRequestParamsWithPrv; + +export type TSSParamsForMessageWithPrv = TSSParamsForMessage & { + prv: string; + mpcv2PartyId?: 0 | 1; +}; + export interface BitgoHeldBackupKeyShare { commonKeychain?: string; id: string; @@ -728,7 +776,7 @@ export interface ITssUtils { isThirdPartyBackup?: boolean; encryptionVersion?: EncryptionVersion; }): Promise; - signTxRequest(params: { txRequest: string | TxRequest; prv: string; reqId: IRequestTracer }): Promise; + signTxRequest(params: TssSignTxRequestParamsWithPrv): Promise; signTxRequestForMessage(params: TSSParams): Promise; signEddsaTssUsingExternalSigner( txRequest: string | TxRequest, diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts index 41391d7232..54d7f9162a 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts @@ -30,12 +30,15 @@ import { TSSParamsForMessage, TSSParamsForMessageWithPrv, TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, + TssTxRecipientSource, TxRequest, } from '../baseTypes'; import { getTxRequest } from '../../../tss'; import { AShare, DShare, EncryptedNShare, SendShareType, SShare, WShare, OShare } from '../../../tss/ecdsa/types'; import { createShareProof, generateGPGKeyPair, getBitgoGpgPubKey } from '../../opengpgUtils'; import { BitGoBase } from '../../../bitgoBase'; +import { InvalidTransactionError } from '../../../errors'; import { verifyWalletSignature } from '../../../tss/ecdsa/ecdsa'; import { signMessageWithDerivedEcdhKey, verifyEcdhSignature } from '../../../ecdh'; import { getTxRequestChallenge } from '../../../tss/common'; @@ -745,6 +748,16 @@ export class EcdsaUtils extends BaseEcdsaUtils { const unsignedTx = txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + if ( + 'recipientSource' in params && + params.recipientSource === TssTxRecipientSource.Explicit && + !params.txParams?.recipients?.length + ) { + throw new InvalidTransactionError( + 'recipientSource "explicit" requires txParams.recipients with at least one recipient.' + ); + } + // For ICP transactions, the HSM signs the serializedTxHex, while the user signs the signableHex separately. // Verification cannot be performed directly on the signableHex alone. However, we can parse the serializedTxHex // to regenerate the signableHex and compare it against the provided value for verification. @@ -862,9 +875,11 @@ export class EcdsaUtils extends BaseEcdsaUtils { * @param {string | TxRequest} params.txRequest - transaction request object or id * @param {string} params.prv - decrypted private key * @param {string} params.reqId - request id + * @param params.recipientSource - optional; use TssTxRecipientSource.Explicit with a non-empty + * txParams.recipients list when you want TypeScript to enforce passing recipient details at compile time. * @returns {Promise} fully signed TxRequest object */ - async signTxRequest(params: TSSParamsWithPrv): Promise { + async signTxRequest(params: TssSignTxRequestParamsWithPrv): Promise { this.bitgo.setRequestTracer(params.reqId); return this.signRequestBase(params, RequestType.tx); } diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 2c9c4c29e4..9279a60d7f 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -45,12 +45,15 @@ import { TSSParamsForMessage, TSSParamsForMessageWithPrv, TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, + TssTxRecipientSource, TxRequest, isV2Envelope, } from '../baseTypes'; import { BaseEcdsaUtils } from './base'; import { EcdsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; +import { InvalidTransactionError } from '../../../errors'; export class EcdsaMPCv2Utils extends BaseEcdsaUtils { private static readonly DKLS23_SIGNING_USER_GPG_KEY = 'DKLS23_SIGNING_USER_GPG_KEY'; @@ -732,10 +735,12 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { * @param {string} params.prv - decrypted private key * @param {string} params.reqId - request id * @param {string} params.mpcv2PartyId - party id for the signer involved in this mpcv2 request (either 0 for user or 1 for backup) + * @param params.recipientSource - optional; use TssTxRecipientSource.Explicit with a non-empty txParams.recipients + * list when you want TypeScript to enforce passing recipient details at compile time. * @returns {Promise} fully signed TxRequest object */ - async signTxRequest(params: TSSParamsWithPrv): Promise { + async signTxRequest(params: TssSignTxRequestParamsWithPrv): Promise { this.bitgo.setRequestTracer(params.reqId); return this.signRequestBase(params, RequestType.tx); } @@ -776,6 +781,16 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const unsignedTx = txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + if ( + 'recipientSource' in params && + params.recipientSource === TssTxRecipientSource.Explicit && + !params.txParams?.recipients?.length + ) { + throw new InvalidTransactionError( + 'recipientSource "explicit" requires txParams.recipients with at least one recipient.' + ); + } + // For ICP transactions, the HSM signs the serializedTxHex, while the user signs the signableHex separately. // Verification cannot be performed directly on the signableHex alone. However, we can parse the serializedTxHex // to regenerate the signableHex and compare it against the provided value for verification. diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts index b69e4821b8..520cd6a207 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -30,10 +30,13 @@ import { SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, + TssTxRecipientSource, TxRequest, UnsignedTransactionTss, isV2Envelope, } from '../baseTypes'; +import { InvalidTransactionError } from '../../../errors'; import { CreateEddsaBitGoKeychainParams, CreateEddsaKeychainParams, KeyShare, YShare } from './types'; import baseTSSUtils from '../baseTSSUtils'; import { BaseEddsaUtils } from './base'; @@ -610,7 +613,7 @@ export class EddsaUtils extends baseTSSUtils { @param params - parameters for signing the transaction request * @returns {Promise} fully signed TxRequest object */ - async signTxRequest(params: TSSParamsWithPrv): Promise { + async signTxRequest(params: TssSignTxRequestParamsWithPrv): Promise { return this.signRequestBase(params, RequestType.tx); } @@ -624,6 +627,16 @@ export class EddsaUtils extends baseTSSUtils { const { txRequest, prv } = params; + if ( + 'recipientSource' in params && + params.recipientSource === TssTxRecipientSource.Explicit && + !params.txParams?.recipients?.length + ) { + throw new InvalidTransactionError( + 'recipientSource "explicit" requires txParams.recipients with at least one recipient.' + ); + } + if (typeof txRequest === 'string') { txRequestResolved = await getTxRequest(this.bitgo, this.wallet.id(), txRequest, params.reqId); txRequestId = txRequestResolved.txRequestId; From 844714211a53262013222a93f4943b2ad9053fe6 Mon Sep 17 00:00:00 2001 From: Michael McShinsky Date: Wed, 6 May 2026 13:28:40 -0700 Subject: [PATCH 2/2] fix(bitgo): format mpcv2 signTxRequest recipientSource test Collapse rejectedWith onto one line for prettier/eslint. WAL-375 Co-authored-by: Cursor --- .../v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts index e48b839ee5..1ec4f59f2e 100644 --- a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts @@ -237,9 +237,7 @@ describe('signTxRequest:', function () { recipientSource: TssTxRecipientSource.Explicit, txParams: { recipients: [] }, } as any) - .should.be.rejectedWith( - 'recipientSource "explicit" requires txParams.recipients with at least one recipient.' - ); + .should.be.rejectedWith('recipientSource "explicit" requires txParams.recipients with at least one recipient.'); }); it('successfully signs a txRequest with backup key for a dkls hot wallet with WP', async function () {