Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions modules/bitgo/test/v2/unit/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,4 +706,58 @@ 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 () => {
// EVM coin: canRecreateTransaction is true with a walletPassphrase, so
// without the message-sign guard the auto-sign below would run.
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' });

// Message requests have no unsigned transaction; the post-approve
// auto-sign must never run for them (it reads transactions[0].unsignedTx).
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();
});
});
});
15 changes: 15 additions & 0 deletions modules/sdk-core/src/bitgo/pendingApproval/iPendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export interface PendingApprovalInfo {
};
sourceWallet?: string;
};
/**
* Present on transactionRequestFull pending approvals. `intent` is echoed
* by wallet-platform (with finalizeUnwrap secrets stripped) and is the only
* client-visible signal for message-sign approvals: message requests carry
* intentType 'signMessage' or 'signTypedStructuredData' and have NO
* unsigned transaction, so post-approval auto-sign must not run for them.
*/
transactionRequestFull?: {
txRequestId?: string;
intent?: {
intentType?: string;
[index: string]: unknown;
};
[index: string]: unknown;
};
}

export interface PendingApprovalData {
Expand Down
19 changes: 19 additions & 0 deletions modules/sdk-core/src/bitgo/pendingApproval/pendingApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,12 +489,31 @@ export class PendingApproval implements IPendingApproval {
reqId
);
} else if (this.canRecreateTransaction(params) && this.baseCoin.supportsTss()) {
if (this.isMessageSigningRequest()) {
// Message-sign approvals (WalletConnect personal_sign / EIP-712)
// have no unsigned transaction: the auto recreate-and-sign below
// reads transactions[0].unsignedTx and crashes on them. The
// message is signed separately via signAndSendMessageTxRequest
// once the approval resolves, so approving ends here.
return;
}
await this.recreateAndSignTSSTransaction(params, reqId);
}
}
}
}

/**
* True when this approval guards a message-sign txRequest (WalletConnect
* personal_sign / EIP-712 typed data). Detected from the PA's echoed
* intent — message requests carry no unsigned transaction, so callers that
* assume a transaction (recreate-and-sign, txHex reads) must skip them.
*/
private isMessageSigningRequest(): boolean {
const intentType = this.info()?.transactionRequestFull?.intent?.intentType;
return intentType === 'signMessage' || intentType === 'signTypedStructuredData';
}

/**
* Helper function to ensure that self.wallet is set
*/
Expand Down
18 changes: 15 additions & 3 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 as 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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 &&
Expand Down
Loading