From 88303697dbafab26abc1e800d88f5c528fd50ef1 Mon Sep 17 00:00:00 2001 From: bitgobot Date: Thu, 5 Nov 2026 09:16:59 +0000 Subject: [PATCH] feat(sdk-core): forward WalletConnect message origin Forward the optional origin marker from message and typed-data signing to the corresponding request intents. WalletConnect message requests need the marker so wallet-platform can classify their origin and surface security-check details. Ticket: DEFI-1069 Session-Id: 8d487d8e-6f3c-4f43-800e-e821a42bc095 Task-Id: 7521e2e4-2ed0-4ff5-ab9c-25d0b172ce0f --- modules/sdk-core/CHANGELOG.md | 4 + .../src/bitgo/utils/tss/baseTSSUtils.ts | 3 + .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 4 + modules/sdk-core/src/bitgo/wallet/iWallet.ts | 5 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 3 + .../test/unit/bitgo/utils/tss/baseTSSUtils.ts | 192 ++++++++++++++++++ 6 files changed, 211 insertions(+) diff --git a/modules/sdk-core/CHANGELOG.md b/modules/sdk-core/CHANGELOG.md index fae39f2b2e6..c7eb0e933ee 100644 --- a/modules/sdk-core/CHANGELOG.md +++ b/modules/sdk-core/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## Unreleased + +* **sdk-core:** Allow WalletConnect message and typed-data signing requests to carry `isWalletConnectTx: true`. + # [40.5.0](https://github.com/BitGo/BitGoJS/compare/%40bitgo%2Fsdk-core%4040.4.0...%40bitgo%2Fsdk-core%4040.5.0) (2026-11-03) ### Bug Fixes diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index 8b18952e786..78cc1765706 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -462,6 +462,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil isTss: params.isTss, messageRaw: params.messageRaw, messageEncoded: params.messageEncoded ?? '', + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; return this.createTxRequestBase(intentOptions, apiVersion, preview, params.reqId); @@ -498,6 +499,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil messageEncoded: params.messageEncoded ?? '', signerAddress: params.signerAddress, preparedTransaction: params.preparedTransaction, + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; return this.buildSignMessageRequestBase(intent, apiVersion, params.reqId); @@ -524,6 +526,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil isTss: params.isTss, messageRaw: params.typedDataRaw, messageEncoded: params.typedDataEncoded ?? '', + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; return this.createTxRequestBase(intentOptions, apiVersion, preview, params.reqId); diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 0473a7ee397..c01b3fe4282 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -324,6 +324,7 @@ export interface UnwrapIntentParams { } export interface IntentOptionsForMessage extends IntentOptionsBase { + isWalletConnectTx?: true; messageRaw: string; messageEncoded?: string; messageStandardType?: MessageStandardType; @@ -333,6 +334,7 @@ export interface IntentOptionsForMessage extends IntentOptionsBase { } export interface IntentOptionsForTypedData extends IntentOptionsBase { + isWalletConnectTx?: true; typedDataRaw: string; typedDataEncoded?: string; } @@ -460,6 +462,7 @@ interface PopulatedIntentBase { } export interface PopulatedIntentForMessageSigning extends PopulatedIntentBase { + isWalletConnectTx?: true; messageRaw: string; messageEncoded: string; custodianMessageId?: string; @@ -470,6 +473,7 @@ export interface PopulatedIntentForMessageSigning extends PopulatedIntentBase { } export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase { + isWalletConnectTx?: true; messageRaw: string; messageEncoded: string; custodianMessageId?: string; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 107962047e9..2194459f0a2 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -450,6 +450,11 @@ export interface WalletSignTransactionOptions extends WalletSignBaseOptions { } interface WalletSignMessageBase extends WalletSignBaseOptions { + /** + * Identifies a new WalletConnect message signing request for classification, not authorization. + * When resuming an existing request by txRequestId, its original metadata is retained. + */ + isWalletConnectTx?: true; walletPassphrase?: string; custodianMessageId?: string; customPaillierModulusGeneratingFunction?: CustomPaillierModulusGetterFunction; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 3a371108016..088b016a862 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2632,6 +2632,7 @@ export class Wallet implements IWallet { messageStandardType, signerAddress: params.message.signerAddress, preparedTransaction: params.message.preparedTransaction, + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; if (!this.tssUtils) { @@ -5510,6 +5511,7 @@ export class Wallet implements IWallet { messageStandardType: params.message.messageStandardType, signerAddress: params.message.signerAddress, preparedTransaction: params.message.preparedTransaction, + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; txRequest = await this.tssUtils!.buildSignMessageRequest(intentOption); params.message.txRequestId = txRequest.txRequestId; @@ -5596,6 +5598,7 @@ export class Wallet implements IWallet { isTss: true, typedDataRaw: params.typedData.typedDataRaw, typedDataEncoded: params.typedData.typedDataEncoded!.toString('hex'), + ...(params.isWalletConnectTx !== undefined && { isWalletConnectTx: params.isWalletConnectTx }), }; txRequest = await this.tssUtils!.createTxRequestWithIntentForTypedDataSigning(intentOptions); params.typedData.txRequestId = txRequest.txRequestId; 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 ac33e733fa1..c250898f29f 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/baseTSSUtils.ts @@ -8,6 +8,9 @@ import { DklsUtils } from '@bitgo/sdk-lib-mpc'; import { BitGoBase, EcdsaMPCv2Utils, IBaseCoin, IWallet, RequestTracer, TxRequest } from '../../../../../src'; import BaseTssUtils from '../../../../../src/bitgo/utils/tss/baseTSSUtils'; +import { Wallet } from '../../../../../src/bitgo/wallet/wallet'; +import { MessageStandardType } from '../../../../../src/bitgo/utils/messageTypes'; +import { SignTypedDataVersion } from '../../../../../src/bitgo/baseCoin/iBaseCoin'; type BitgoGpgKeyPair = openpgp.SerializedKeyPair & { revocationCertificate: string }; type TxRequestTransaction = NonNullable[number]; @@ -108,6 +111,195 @@ describe('Base TSS Utils', function () { ecdsaMPCv2Utils = new EcdsaMPCv2Utils(mockBitgo, mockCoin); }); + describe('message request WalletConnect marker', function () { + for (const isWalletConnectTx of [true, undefined] as const) { + for (const request of ['msgrequests', 'message txrequests', 'typed data txrequests'] as const) { + it(`forwards ${String(isWalletConnectTx)} to ${request}`, async function () { + const send = sinon.stub().returns({ result: sinon.stub().resolves(buildTxRequest()) }); + mockBitgo.post = sinon.stub().returns({ send }); + mockBitgo.url = sinon.stub().callsFake((url: string) => url); + mockBitgo.setRequestTracer = sinon.stub(); + const wallet = { id: sinon.stub().returns('wallet-id') } as unknown as IWallet; + const coin = {} as IBaseCoin; + const utils = new TestBaseTssUtils(mockBitgo, coin, wallet); + const reqId = new RequestTracer(); + const marker: { isWalletConnectTx?: true } = isWalletConnectTx === undefined ? {} : { isWalletConnectTx }; + + if (request === 'typed data txrequests') { + await utils.createTxRequestWithIntentForTypedDataSigning({ + reqId, + intentType: 'signTypedStructuredData', + typedDataRaw: '{}', + ...marker, + }); + } else { + const options = { reqId, intentType: 'signMessage', messageRaw: 'hello', ...marker }; + if (request === 'msgrequests') { + await utils.buildSignMessageRequest(options); + } else { + await utils.createTxRequestWithIntentForMessageSigning(options); + } + } + + sinon.assert.calledOnce(send); + const body = send.firstCall.args[0]; + assert.strictEqual(body.intent.isWalletConnectTx, isWalletConnectTx); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(body.intent, 'isWalletConnectTx'), + isWalletConnectTx === true + ); + assert.strictEqual(body.intent.messageRaw, request === 'typed data txrequests' ? '{}' : 'hello'); + assert.strictEqual( + body.intent.intentType, + request === 'typed data txrequests' ? 'signTypedStructuredData' : 'signMessage' + ); + assert.strictEqual(Object.prototype.hasOwnProperty.call(body.intent, 'txChannel'), false); + sinon.assert.calledWith( + mockBitgo.post as sinon.SinonStub, + `/wallet/wallet-id/${request === 'msgrequests' ? 'msgrequests' : 'txrequests'}` + ); + }); + } + } + }); + + describe('wallet message signing WalletConnect marker', function () { + for (const isWalletConnectTx of [true, undefined] as const) { + for (const { operation, coinName, messageStandardType } of [ + { operation: 'signMessage', coinName: 'teth', messageStandardType: MessageStandardType.EIP191 }, + { operation: 'signMessage', coinName: 'tsol', messageStandardType: MessageStandardType.SIMPLE }, + { operation: 'buildSignMessageRequest', coinName: 'teth', messageStandardType: MessageStandardType.EIP191 }, + { operation: 'buildSignMessageRequest', coinName: 'tsol', messageStandardType: MessageStandardType.SIMPLE }, + { operation: 'signTypedData', coinName: 'teth', messageStandardType: MessageStandardType.EIP712 }, + ] as const) { + it(`forwards ${String(isWalletConnectTx)} from ${coinName} ${operation}`, async function () { + const response = buildTxRequest({ + apiVersion: 'full', + state: 'pendingApproval', + pendingApprovalId: 'approval-id', + }); + const send = sinon.stub().returns({ result: sinon.stub().resolves(response) }); + mockBitgo.post = sinon.stub().returns({ send }); + mockBitgo.url = sinon.stub().callsFake((url: string) => url); + mockBitgo.setRequestTracer = sinon.stub(); + const coin = { + supportsTss: () => false, + supportsMessageSigning: () => true, + supportsSigningTypedData: () => true, + keychains: () => ({ getKeysForSigning: async () => [{ commonKeychain: 'key' }] }), + } as unknown as IBaseCoin; + const wallet = new Wallet(mockBitgo, coin, { id: 'wallet-id', coin: coinName, multisigType: 'tss' }); + Object.defineProperty(wallet, 'tssUtils', { value: new TestBaseTssUtils(mockBitgo, coin, wallet) }); + sinon.stub(wallet, 'getUserPrv').resolves('prv'); + const marker: { isWalletConnectTx?: true } = isWalletConnectTx === undefined ? {} : { isWalletConnectTx }; + if (operation === 'signTypedData') { + const result = await wallet.signTypedData({ + typedData: { + typedDataRaw: '{}', + typedDataEncoded: Buffer.from('abcd', 'hex'), + version: SignTypedDataVersion.V4, + }, + ...marker, + }); + assert.deepStrictEqual(result, { + state: 'pendingApproval', + coin: coinName, + messageRaw: '{}', + txRequestId: 'tx-request-id', + pendingApprovalId: 'approval-id', + }); + } else { + const options = { + message: { messageRaw: 'hello', messageStandardType }, + ...marker, + }; + if (operation === 'signMessage') { + const result = await wallet.signMessage(options); + assert.deepStrictEqual(result, { + state: 'pendingApproval', + coin: coinName, + messageRaw: 'hello', + txRequestId: 'tx-request-id', + pendingApprovalId: 'approval-id', + }); + } else { + await wallet.buildSignMessageRequest(options); + } + } + sinon.assert.calledOnce(send); + const intent = send.firstCall.args[0].intent; + assert.strictEqual(intent.isWalletConnectTx, isWalletConnectTx); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(intent, 'isWalletConnectTx'), + isWalletConnectTx === true + ); + assert.strictEqual( + intent.intentType, + operation === 'signTypedData' ? 'signTypedStructuredData' : 'signMessage' + ); + assert.strictEqual(intent.messageRaw, operation === 'signTypedData' ? '{}' : 'hello'); + assert.strictEqual(intent.messageEncoded, operation === 'signTypedData' ? 'abcd' : ''); + if (operation !== 'signTypedData') { + assert.strictEqual(intent.messageStandardType, messageStandardType); + } + assert.strictEqual(Object.prototype.hasOwnProperty.call(intent, 'txChannel'), false); + sinon.assert.calledWith( + mockBitgo.post as sinon.SinonStub, + `/wallet/wallet-id/${operation === 'signTypedData' ? 'txrequests' : 'msgrequests'}` + ); + }); + } + } + }); + + describe('resuming message requests with WalletConnect metadata', function () { + for (const operation of ['signMessage', 'signTypedData'] as const) { + it(`reuses the existing request for ${operation}`, async function () { + const response = buildTxRequest({ + apiVersion: 'full', + state: 'pendingApproval', + pendingApprovalId: 'approval-id', + }); + const retry = sinon.stub().returns({ result: sinon.stub().resolves({ txRequests: [response] }) }); + const query = sinon.stub().returns({ retry }); + mockBitgo.get = sinon.stub().returns({ query }); + mockBitgo.post = sinon.stub(); + mockBitgo.url = sinon.stub().callsFake((url: string) => url); + mockBitgo.setRequestTracer = sinon.stub(); + const coin = { + supportsTss: () => false, + supportsMessageSigning: () => true, + supportsSigningTypedData: () => true, + keychains: () => ({ getKeysForSigning: async () => [{ commonKeychain: 'key' }] }), + } as unknown as IBaseCoin; + const wallet = new Wallet(mockBitgo, coin, { id: 'wallet-id', coin: 'teth', multisigType: 'tss' }); + Object.defineProperty(wallet, 'tssUtils', { value: new TestBaseTssUtils(mockBitgo, coin, wallet) }); + sinon.stub(wallet, 'getUserPrv').resolves('prv'); + + if (operation === 'signMessage') { + await wallet.signMessage({ + isWalletConnectTx: true, + message: { messageRaw: 'hello', messageStandardType: MessageStandardType.EIP191, txRequestId: 'existing' }, + }); + } else { + await wallet.signTypedData({ + isWalletConnectTx: true, + typedData: { + typedDataRaw: '{}', + typedDataEncoded: Buffer.from('abcd', 'hex'), + version: SignTypedDataVersion.V4, + txRequestId: 'existing', + }, + }); + } + + sinon.assert.calledOnce(mockBitgo.get as sinon.SinonStub); + sinon.assert.calledWith(query, { txRequestIds: 'existing', latest: 'true' }); + sinon.assert.notCalled(mockBitgo.post as sinon.SinonStub); + }); + } + }); + describe('getSignableHexAndDerivationPath', function () { it('extracts signableHex and derivationPath from a valid txRequest', function () { const txRequest = buildTxRequest({