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 9b2a43be4e..2a5e7fda03 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,31 @@ 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('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 e4dfebe65c..a80af8b632 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'; @@ -198,7 +198,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 f773e370a7..70acbc4a2e 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 { 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'; @@ -532,16 +532,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 = { @@ -557,6 +547,60 @@ 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; +}; + +export type TSSParamsWithPrv = TssSignTxRequestParamsWithPrv; + +export type TSSParamsForMessageWithPrv = TSSParamsForMessage & { + prv: string; + mpcv2PartyId?: 0 | 1; +}; + export interface BitgoHeldBackupKeyShare { commonKeychain?: string; id: string; @@ -714,7 +758,7 @@ export interface ITssUtils { originalPasscodeEncryptionCode?: string; isThirdPartyBackup?: boolean; }): 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 6c36191979..0ea3304420 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -44,11 +44,14 @@ import { TSSParamsForMessage, TSSParamsForMessageWithPrv, TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, + TssTxRecipientSource, TxRequest, } 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'; @@ -697,10 +700,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); } @@ -741,6 +746,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 de4790dfd5..ad2ad6b3ef 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsa.ts @@ -30,6 +30,7 @@ import { SignatureShareType, TSSParamsForMessageWithPrv, TSSParamsWithPrv, + TssSignTxRequestParamsWithPrv, TxRequest, UnsignedTransactionTss, } from '../baseTypes'; @@ -571,7 +572,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); }