diff --git a/modules/bitgo/test/v2/unit/pendingApproval.ts b/modules/bitgo/test/v2/unit/pendingApproval.ts index 3a71b58d1d..761db1785b 100644 --- a/modules/bitgo/test/v2/unit/pendingApproval.ts +++ b/modules/bitgo/test/v2/unit/pendingApproval.ts @@ -706,4 +706,75 @@ describe('Pending Approvals:', () => { }); }); }); + + describe('message-sign approvals (transactionRequestFull)', () => { + const buildMessageSignPendingApprovalData = (intentType: string): PendingApprovalData => ({ + id: 'pa-msg', + info: { + type: Type.TRANSACTION_REQUEST_FULL, + transactionRequestFull: { + txRequestId: 'txreq-msg-1', + intent: { intentType }, + }, + } as PendingApprovalInfo, + state: State.PENDING, + creator: 'test', + txRequestId: 'txreq-msg-1', + }); + + ['signMessage', 'signTypedStructuredData'].forEach((intentType) => { + it(`should approve without the post-approve auto-sign for a ${intentType} approval`, async () => { + const ethBaseCoin = bitgo.coin('teth'); + const paData = buildMessageSignPendingApprovalData(intentType); + const pendingApproval = new PendingApproval(bitgo, ethBaseCoin, paData, wallet); + + const paScope = nock(bgUrl) + .put(`/api/v2/teth/pendingapprovals/pa-msg`, { state: 'approved', otp: undefined }) + .reply(200, { ...paData, state: 'approved' }); + const recreateTransactionTssStub = sandbox.stub(PendingApproval.prototype, 'recreateAndSignTSSTransaction'); + + await pendingApproval.approve({ walletPassphrase: 'pass' }); + + recreateTransactionTssStub.notCalled.should.be.true(); + paScope.isDone().should.be.true(); + }); + }); + + it('should keep the post-approve auto-sign for transaction approvals', async () => { + const ethBaseCoin = bitgo.coin('teth'); + const paData = buildMessageSignPendingApprovalData('payment'); + const pendingApproval = new PendingApproval(bitgo, ethBaseCoin, paData, wallet); + + const paScope = nock(bgUrl) + .put(`/api/v2/teth/pendingapprovals/pa-msg`, { state: 'approved', otp: undefined }) + .reply(200, { ...paData, state: 'approved' }); + const recreateTransactionTssStub = sandbox.stub(PendingApproval.prototype, 'recreateAndSignTSSTransaction'); + + await pendingApproval.approve({ walletPassphrase: 'pass' }); + + recreateTransactionTssStub.calledOnce.should.be.true(); + paScope.isDone().should.be.true(); + }); + + it('should detect a message-sign request from messages when intent is absent', async () => { + const ethBaseCoin = bitgo.coin('teth'); + const paData = buildMessageSignPendingApprovalData('payment'); + const full = paData.info.transactionRequestFull; + if (full) { + delete full.intent; + full.messages = [{ messageRaw: 'hello' }]; + } + const pendingApproval = new PendingApproval(bitgo, ethBaseCoin, paData, wallet); + + const paScope = nock(bgUrl) + .put(`/api/v2/teth/pendingapprovals/pa-msg`, { state: 'approved', otp: undefined }) + .reply(200, { ...paData, state: 'approved' }); + const recreateTransactionTssStub = sandbox.stub(PendingApproval.prototype, 'recreateAndSignTSSTransaction'); + + await pendingApproval.approve({ walletPassphrase: 'pass' }); + + recreateTransactionTssStub.notCalled.should.be.true(); + paScope.isDone().should.be.true(); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts index 5b33a47e22..6376164a4e 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts @@ -48,8 +48,24 @@ export interface PendingApprovalInfo { }; sourceWallet?: string; }; + /** + * Present on transactionRequestFull pending approvals. intent.intentType is + * MESSAGE_SIGNING_INTENT_TYPES for message-sign approvals, which have no + * unsigned transaction. + */ + transactionRequestFull?: { + txRequestId?: string; + intent?: { + intentType?: string; + [index: string]: unknown; + }; + [index: string]: unknown; + }; } +/** Intent types whose txRequest carries a message to sign rather than a transaction. */ +export const MESSAGE_SIGNING_INTENT_TYPES: ReadonlySet = new Set(['signMessage', 'signTypedStructuredData']); + export interface PendingApprovalData { id: string; wallet?: string; diff --git a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts index 4f18b59d69..40f5fb5bab 100644 --- a/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts +++ b/modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts @@ -4,6 +4,7 @@ import * as _ from 'lodash'; import * as utxolib from '@bitgo/utxo-lib'; import type { AttestationPayload } from '@bitgo/public-types'; +import { MESSAGE_SIGNING_INTENT_TYPES } from './iPendingApproval'; import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { @@ -172,7 +173,10 @@ export class PendingApproval implements IPendingApproval { } /** - * Sets this PendingApproval to an approved state + * Sets this PendingApproval to an approved state. + * For transaction approvals on TSS wallets, this also attempts to recreate + * and sign the transaction. For message-sign approvals, signing is deferred: + * the message must be signed separately via signAndSendMessageTxRequest. */ async approve(params: ApproveOptions = {}): Promise { params.previewPendingTxs = true; @@ -489,12 +493,30 @@ export class PendingApproval implements IPendingApproval { reqId ); } else if (this.canRecreateTransaction(params) && this.baseCoin.supportsTss()) { + if (this.isMessageSigningRequest()) { + return; + } await this.recreateAndSignTSSTransaction(params, reqId); } } } } + /** + * True when the approval's intent is a message-sign intent. + */ + private isMessageSigningRequest(): boolean { + const info = this.info()?.transactionRequestFull; + if (info?.intent?.intentType && MESSAGE_SIGNING_INTENT_TYPES.has(info.intent.intentType)) { + return true; + } + // Fallback when intent is absent: a messages array means the request is message-only. + if (info && 'messages' in info && Array.isArray(info.messages) && info.messages.length > 0) { + return true; + } + return false; + } + /** * Helper function to ensure that self.wallet is set */ 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 3ef50fe19e..fece8b88d0 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts @@ -590,11 +590,13 @@ export class EcdsaUtils extends BaseEcdsaUtils { if (requestType === RequestType.tx) { assert( - txRequest.transactions || (txRequest as TxRequest).unsignedTxs, + (txRequest.transactions && txRequest.transactions.length > 0) || + (txRequest.unsignedTxs && txRequest.unsignedTxs.length > 0), 'Unable to find transactions in txRequest' ); const unsignedTx = txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + assert(unsignedTx, 'Missing unsignedTx in txRequest'); derivationPath = unsignedTx.derivationPath; } else if (requestType === RequestType.message) { // TODO BG-67299 Message signing with derivation path @@ -641,9 +643,14 @@ export class EcdsaUtils extends BaseEcdsaUtils { const txRequest: TxRequest = tssParams.txRequest; let signablePayload; if (requestType === RequestType.tx) { - assert(txRequest.transactions || txRequest.unsignedTxs, 'Unable to find transactions in txRequest'); + assert( + (txRequest.transactions && txRequest.transactions.length > 0) || + (txRequest.unsignedTxs && txRequest.unsignedTxs.length > 0), + 'Unable to find transactions in txRequest' + ); const unsignedTx = txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + assert(unsignedTx, 'Missing unsignedTx in txRequest'); signablePayload = Buffer.from(unsignedTx.signableHex, 'hex'); } else if (requestType === RequestType.message) { signablePayload = (params.tssParams as TSSParamsForMessage).bufferToSign; @@ -799,9 +806,14 @@ export class EcdsaUtils extends BaseEcdsaUtils { let derivationPath = ''; if (requestType === RequestType.tx) { - assert(txRequest.transactions || txRequest.unsignedTxs, 'Unable to find transactions in txRequest'); + assert( + (txRequest.transactions && txRequest.transactions.length > 0) || + (txRequest.unsignedTxs && txRequest.unsignedTxs.length > 0), + 'Unable to find transactions in txRequest' + ); const unsignedTx = txRequest.apiVersion === 'full' ? txRequest.transactions![0].unsignedTx : txRequest.unsignedTxs[0]; + assert(unsignedTx, 'Missing unsignedTx in txRequest'); if ( 'recipientSource' in params &&