diff --git a/examples/ts/build-message.ts b/examples/ts/build-message.ts new file mode 100644 index 0000000000..f87cae2bec --- /dev/null +++ b/examples/ts/build-message.ts @@ -0,0 +1,40 @@ +/** + * Pre-build a message from the wallet + * + * This tool will help you see how to use the BitGo API to easily build + * a message from a wallet. + * + * Copyright 2025, BitGo, Inc. All Rights Reserved. + */ + +import {BitGoAPI} from '@bitgo/sdk-api'; +import {Hteth} from "@bitgo/sdk-coin-eth"; +import {MessageStandardType} from "@bitgo/sdk-core"; // Replace with your given coin (e.g. Ltc, Tltc) +require('dotenv').config({ path: '../../.env' }); + +const bitgo = new BitGoAPI({ + accessToken: process.env.TESTNET_ACCESS_TOKEN, + env: 'test', // Change this to env: 'production' when you are ready for production +}); + +// Set the coin name to match the blockchain and network +// doge = dogecoin, tdoge = testnet dogecoin +const coin = 'hteth'; +bitgo.register(coin, Hteth.createInstance); + +const id = ''; + +async function main() { + const wallet = await bitgo.coin(coin).wallets().get({ id }); + console.log(`Wallet label: ${wallet.label()}`); + + const txRequest = await wallet.buildSignMessageRequest({ + message: { + messageRaw: 'Hello, BitGo!', + messageStandardType: MessageStandardType.EIP191, + }, + }); + console.dir(txRequest); +} + +main().catch((e) => console.log(e)); diff --git a/examples/ts/sign-message.ts b/examples/ts/sign-message.ts new file mode 100644 index 0000000000..7f87ea0ecd --- /dev/null +++ b/examples/ts/sign-message.ts @@ -0,0 +1,32 @@ +/** + * Sign a Message from an MPC wallet at BitGo. + * + * Copyright 2025, BitGo, Inc. All Rights Reserved. + */ +import { BitGo } from 'bitgo'; +import { MessageStandardType } from '@bitgo/sdk-core'; + +const bitgo = new BitGo({ env: 'test' }); + +const coin = 'hteth'; +const basecoin = bitgo.coin(coin); +const accessToken = ''; +const walletId = ''; +const walletPassphrase = ''; + +async function signMessage(): Promise { + await bitgo.authenticateWithAccessToken({ accessToken }); + const walletInstance = await basecoin.wallets().get({ id: walletId }); + + const messageTxn = await walletInstance.signMessage({ + message: { + messageRaw: 'Hello BitGo!', + messageStandardType: MessageStandardType.EIP191, + }, + walletPassphrase, + }); + + console.log(messageTxn); +} + +signMessage().catch(console.error); diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index d50fce6595..0f8b12a9f9 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -9,33 +9,34 @@ import * as nock from 'nock'; import * as _ from 'lodash'; import { + BaseTssUtils, common, CustomSigningFunction, + Ecdsa, ECDSAUtils, EDDSAUtils, + GetUserPrvOptions, + Keychains, + KeyType, + ManageUnspentsOptions, + MessageStandardType, + MessageTypes, + PopulatedIntent, + PrebuildTransactionWithIntentOptions, RequestTracer, + SendManyOptions, + SignatureShareType, + SignedMessage, + SignTypedDataVersion, TokenType, TssUtils, TxRequest, - Wallet, - SignatureShareType, - Ecdsa, - Keychains, + TxRequestVersion, TypedData, TypedMessage, - MessageTypes, - SignTypedDataVersion, - GetUserPrvOptions, - ManageUnspentsOptions, - SignedMessage, - BaseTssUtils, - KeyType, - SendManyOptions, - PopulatedIntent, - TxRequestVersion, + Wallet, WalletSignMessageOptions, WalletSignTypedDataOptions, - PrebuildTransactionWithIntentOptions, } from '@bitgo/sdk-core'; import { TestBitGo } from '@bitgo/sdk-test'; @@ -3414,6 +3415,10 @@ describe('V2 Wallet:', function () { describe('Message Signing', function () { const txHash = '0xrrrsss1b'; + const messageRaw = 'hello world'; + const messageEncoded = Buffer.from(`\u0019Ethereum Signed Message:\n${messageRaw.length}${messageRaw}`).toString( + 'hex' + ); const txRequestForMessageSigning: TxRequest = { txRequestId: reqId.toString(), transactions: [], @@ -3438,19 +3443,19 @@ describe('V2 Wallet:', function () { signatureShares: [{ from: SignatureShareType.USER, to: SignatureShareType.USER, share: '' }], combineSigShare: '0:rrr:sss:3', txHash, + messageEncoded, }, ], }; let signTxRequestForMessage; const messageSigningCoins = ['teth', 'tpolygon']; - const messageRaw = 'test'; const expected: SignedMessage = { txRequestId: reqId.toString(), txHash, signature: txHash, messageRaw, coin: 'teth', - messageEncoded: Buffer.from('\u0019Ethereum Signed Message:\n4test').toString('hex'), + messageEncoded, }; beforeEach(async function () { @@ -3467,14 +3472,27 @@ describe('V2 Wallet:', function () { nock.cleanAll(); }); - it('should throw error for unsupported coins', async function () { - await tssSolWallet - .signMessage({ - reqId, - message: { messageRaw }, - prv: 'secretKey', - }) - .should.be.rejectedWith('Message signing not supported for Testnet Solana'); + describe('should throw error for unsupported coins', function () { + it('sol signMessage', async function () { + await tssSolWallet + .signMessage({ + reqId, + message: { messageRaw }, + prv: 'secretKey', + }) + .should.be.rejectedWith('Message signing not supported for Testnet Solana'); + }); + + it('sol create signMessage tx request', async function () { + await tssSolWallet + .buildSignMessageRequest({ + message: { + messageRaw, + messageStandardType: MessageStandardType.EIP191, + }, + }) + .should.be.rejectedWith('Message signing not supported for Testnet Solana'); + }); }); messageSigningCoins.map((coinName) => { @@ -3483,7 +3501,19 @@ describe('V2 Wallet:', function () { tssEthWallet = new Wallet(bitgo, bitgo.coin(coinName), ethWalletData); const txRequestId = txRequestForMessageSigning.txRequestId; - it('should sign message', async function () { + it('should create tx Request with signMessage intent', async function () { + nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/msgrequests`).reply(200, txRequestForMessageSigning); + + const txRequest = await tssEthWallet.buildSignMessageRequest({ + message: { + messageRaw, + messageStandardType: MessageStandardType.EIP191, + }, + }); + txRequest.should.deepEqual(txRequestForMessageSigning); + }); + + it(`[${coinName}] should sign message`, async function () { const signMessageTssSpy = sinon.spy(tssEthWallet, 'signMessageTss' as any); nock(bgUrl) .get( @@ -3495,7 +3525,7 @@ describe('V2 Wallet:', function () { const signMessage = await tssEthWallet.signMessage({ reqId, - message: { messageRaw, txRequestId }, + message: { messageRaw, txRequestId, messageStandardType: MessageStandardType.EIP191 }, prv: 'secretKey', }); signMessage.should.deepEqual(expectedWithCoinField); @@ -3505,14 +3535,14 @@ describe('V2 Wallet:', function () { ); }); - it('should sign message when custodianMessageId is provided', async function () { + it(`[${coinName}] should sign message when custodianMessageId is provided`, async function () { const signMessageTssSpy = sinon.spy(tssEthWallet, 'signMessageTss' as any); - nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/txrequests`).reply(200, txRequestForMessageSigning); + nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/msgrequests`).reply(200, txRequestForMessageSigning); const signMessage = await tssEthWallet.signMessage({ custodianMessageId: 'unittest', reqId, - message: { messageRaw }, + message: { messageRaw, messageStandardType: MessageStandardType.EIP191 }, prv: 'secretKey', }); signMessage.should.deepEqual(expectedWithCoinField); @@ -3522,13 +3552,13 @@ describe('V2 Wallet:', function () { ); }); - it('should sign message when txRequestId not provided', async function () { + it(`[${coinName}] should sign message when txRequestId not provided`, async function () { const signMessageTssSpy = sinon.spy(tssEthWallet, 'signMessageTss' as any); - nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/txrequests`).reply(200, txRequestForMessageSigning); + nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/msgrequests`).reply(200, txRequestForMessageSigning); const signMessage = await tssEthWallet.signMessage({ reqId, - message: { messageRaw }, + message: { messageRaw, messageStandardType: MessageStandardType.EIP191 }, prv: 'secretKey', }); signMessage.should.deepEqual(expectedWithCoinField); @@ -3542,7 +3572,7 @@ describe('V2 Wallet:', function () { await tssEthWallet .signMessage({ reqId, - message: { messageRaw, txRequestId }, + message: { messageRaw, txRequestId, messageStandardType: MessageStandardType.EIP191 }, prv: '', }) .should.be.rejectedWith('keychain does not have property encryptedPrv'); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index f54174d0ef..aa3eff7aac 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -419,6 +419,7 @@ export interface SignedMessage { signature: string; messageRaw: string; messageEncoded?: string; + messageStandardType?: MessageStandardType; txRequestId: string; } diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts index fdd24e2c5d..e15022d991 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTSSUtils.ts @@ -1,4 +1,5 @@ import { IRequestTracer } from '../../../api'; +import * as openpgp from 'openpgp'; import { Key, readKey, SerializedKeyPair } from 'openpgp'; import { IBaseCoin, KeychainsTriplet } from '../../baseCoin'; import { BitGoBase } from '../../bitgoBase'; @@ -10,39 +11,40 @@ import * as _ from 'lodash'; import { BitgoGPGPublicKey, BitgoHeldBackupKeyShare, + CommitmentShareRecord, + CreateBitGoKeychainParamsBase, + CreateKeychainParamsBase, + CustomCommitmentGeneratingFunction, CustomGShareGeneratingFunction, + CustomKShareGeneratingFunction, + CustomMPCv2SigningRound1GeneratingFunction, + CustomMPCv2SigningRound2GeneratingFunction, + CustomMPCv2SigningRound3GeneratingFunction, + CustomMuDeltaShareGeneratingFunction, + CustomPaillierModulusGetterFunction, CustomRShareGeneratingFunction, + CustomSShareGeneratingFunction, + EncryptedSignerShareRecord, + IntentOptionsForMessage, + IntentOptionsForTypedData, ITssUtils, + PopulatedIntentForMessageSigning, + PopulatedIntentForTypedDataSigning, PrebuildTransactionWithIntentOptions, + RequestType, SignatureShareRecord, TSSParams, - TxRequest, - TxRequestVersion, - CreateKeychainParamsBase, - IntentOptionsForMessage, - PopulatedIntentForMessageSigning, - IntentOptionsForTypedData, - PopulatedIntentForTypedDataSigning, - CreateBitGoKeychainParamsBase, - CommitmentShareRecord, - EncryptedSignerShareRecord, - CustomCommitmentGeneratingFunction, TSSParamsForMessage, - RequestType, - CustomPaillierModulusGetterFunction, - CustomKShareGeneratingFunction, - CustomMuDeltaShareGeneratingFunction, - CustomSShareGeneratingFunction, - CustomMPCv2SigningRound1GeneratingFunction, - CustomMPCv2SigningRound2GeneratingFunction, - CustomMPCv2SigningRound3GeneratingFunction, TSSParamsWithPrv, + TxRequest, + TxRequestVersion, } from './baseTypes'; import { GShare, SignShare } from '../../../account-lib/mpc/tss'; import { RequestTracer } from '../util'; -import * as openpgp from 'openpgp'; import { envRequiresBitgoPubGpgKeyConfig, getBitgoMpcGpgPubKey } from '../../tss/bitgoPubKeys'; import { getBitgoGpgPubKey } from '../opengpgUtils'; +import assert from 'assert'; +import { MessageStandardType } from '../messageTypes'; /** * BaseTssUtil class which different signature schemes have to extend @@ -355,6 +357,7 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil /** * Create a tx request from params for message signing + * @deprecated Use createSignMessageRequest instead * * @param params * @param apiVersion @@ -379,6 +382,35 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil return this.createTxRequestBase(intentOptions, apiVersion, preview, params.reqId); } + /** + * Create a sign message request + * + * @param params - the parameters for the sign message request + * @param apiVersion - the API version to use, defaults to 'full' + */ + async buildSignMessageRequest( + params: IntentOptionsForMessage, + apiVersion: TxRequestVersion = 'full' + ): Promise { + assert( + params.intentType === 'signMessage', + 'Intent type must be signMessage for createMsgRequestWithSignMessageIntent' + ); + const intent: PopulatedIntentForMessageSigning = { + custodianMessageId: params.custodianMessageId, + intentType: params.intentType, + sequenceId: params.sequenceId, + comment: params.comment, + memo: params.memo?.value, + isTss: params.isTss, + messageRaw: params.messageRaw, + messageStandardType: params.messageStandardType ?? MessageStandardType.UNKNOWN, + messageEncoded: params.messageEncoded ?? '', + }; + + return this.buildSignMessageRequestBase(intent, apiVersion, params.reqId); + } + /** * Create a tx request from params for type data signing * @@ -432,6 +464,31 @@ export default class BaseTssUtils extends MpcUtils implements ITssUtil .result(); } + /** + * Calls Bitgo API to create msg request. + * + * @private + */ + private async buildSignMessageRequestBase( + intent: PopulatedIntentForMessageSigning, + apiVersion: TxRequestVersion, + reqId?: IRequestTracer + ): Promise { + const whitelistedParams = { + intent: { + ...intent, + }, + apiVersion, + }; + + const reqTracer = reqId || new RequestTracer(); + this.bitgo.setRequestTracer(reqTracer); + return this.bitgo + .post(this.bitgo.url(`/wallet/${this.wallet.id()}/msgrequests`, 2)) + .send(whitelistedParams) + .result(); + } + /** * Call delete signature shares for a txRequest, the endpoint delete the signatures and return them * diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 442107eb05..e038ef229d 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -9,6 +9,7 @@ import { KeyShare } from './ecdsa'; import { EcdsaTypes } from '@bitgo/sdk-lib-mpc'; import { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage, TxRequestChallengeResponse } from '../../tss/types'; import { AShare, DShare, SShare } from '../../tss/ecdsa/types'; +import { MessageStandardType } from '../messageTypes'; export type TxRequestVersion = 'full' | 'lite'; export interface HopParams { @@ -172,6 +173,7 @@ interface IntentOptionsBase { export interface IntentOptionsForMessage extends IntentOptionsBase { messageRaw: string; messageEncoded?: string; + messageStandardType?: MessageStandardType; } export interface IntentOptionsForTypedData extends IntentOptionsBase { @@ -226,6 +228,7 @@ export interface PopulatedIntentForMessageSigning extends PopulatedIntentBase { messageRaw: string; messageEncoded: string; custodianMessageId?: string; + messageStandardType?: MessageStandardType; } export interface PopulatedIntentForTypedDataSigning extends PopulatedIntentBase { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 0a1ef697dd..89f6462e3f 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -27,6 +27,7 @@ import { CustomSShareGeneratingFunction, TokenEnablement, TokenTransferRecipientParams, + TxRequest, } from '../utils'; import { SerializedNtilde } from '../../account-lib/mpc/tss/ecdsa/types'; import { IAddressBook } from '../address-book'; @@ -909,6 +910,7 @@ export interface IWallet { sendTokenEnablement(params?: PrebuildAndSignTransactionOptions): Promise; sendTokenEnablements(params?: BuildTokenEnablementOptions): Promise; signMessage(params: WalletSignMessageOptions): Promise; + buildSignMessageRequest(params: WalletSignMessageOptions): Promise; signTypedData(params: WalletSignTypedDataOptions): Promise; fetchCrossChainUTXOs(params: FetchCrossChainUTXOsOptions): Promise; getChallengesForEcdsaSigning(): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 3c3869a5c0..4ecc8df6d8 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2068,8 +2068,8 @@ export class Wallet implements IWallet { if (!this.baseCoin.supportsMessageSigning()) { throw new Error(`Message signing not supported for ${this.baseCoin.getFullName()}`); } - if (!params.message) { - throw new Error('message required to sign message'); + if (!params.message || !params.message.messageStandardType) { + throw new Error('message and type required to sign message'); } if (this._wallet.multisigType !== 'tss') { throw new Error('Message signing only supported for TSS wallets'); @@ -2093,6 +2093,48 @@ export class Wallet implements IWallet { return this.signMessageTss(presign); } + /** + * Prepares and creates a sign message request for TSS wallets, that can be used later for signing. + * + * @param params - Parameters for creating the sign message request + * @returns Promise - The created transaction request for signing a message + */ + async buildSignMessageRequest(params: WalletSignMessageOptions): Promise { + if (this._wallet.multisigType !== 'tss') { + throw new Error('Message signing only supported for TSS wallets'); + } + + if (!this.baseCoin.supportsMessageSigning()) { + throw new Error(`Message signing not supported for ${this.baseCoin.getFullName()}`); + } + + if (!params.message?.messageRaw || !params.message?.messageStandardType) { + throw new Error('message and type required to create message sign request'); + } + const messageRaw = params.message.messageRaw; + const messageStandardType = params.message.messageStandardType; + + const reqId = params.reqId || new RequestTracer(); + + try { + const intentOption: IntentOptionsForMessage = { + custodianMessageId: params.custodianMessageId, + reqId, + intentType: 'signMessage', + isTss: true, + messageRaw, + messageStandardType, + }; + + if (!this.tssUtils) { + throw new Error('TSS utilities not available for this wallet'); + } + return await this.tssUtils.buildSignMessageRequest(intentOption); + } catch (error) { + throw new Error(`Failed to create message sign request: ${error}`); + } + } + /** * Get the user private key from either a derivation or an encrypted keychain * @param [params.keychain / params.key] (object) or params.prv (string) @@ -3570,28 +3612,36 @@ export class Wallet implements IWallet { try { let txRequest; assert(params.message, 'message required for message signing'); + const messageRaw = params.message.messageRaw; + if (!params.message.txRequestId) { const intentOption: IntentOptionsForMessage = { custodianMessageId: params.custodianMessageId, reqId: params.reqId, intentType: 'signMessage', isTss: true, - messageRaw: params.message.messageRaw, - messageEncoded: params.message.messageEncoded, + messageRaw, + messageStandardType: params.message.messageStandardType, }; - txRequest = await this.tssUtils!.createTxRequestWithIntentForMessageSigning(intentOption); + txRequest = await this.tssUtils!.buildSignMessageRequest(intentOption); params.message.txRequestId = txRequest.txRequestId; } else { txRequest = await getTxRequest(this.bitgo, this.id(), params.message.txRequestId, params.reqId); } + assert( + txRequest.messages && txRequest.messages.length > 0, + 'Unable to find messages in txRequest for message signing' + ); + const messageEncoded = txRequest.messages[0].messageEncoded; + const signedMessageRequest = await this.tssUtils!.signTxRequestForMessage({ txRequest, prv: params.prv, reqId: params.reqId || new RequestTracer(), - messageRaw: params.message.messageRaw, - messageEncoded: params.message.messageEncoded, - bufferToSign: Buffer.from(params.message.messageEncoded ?? '', 'hex'), + messageRaw, + messageEncoded, + bufferToSign: Buffer.from(messageEncoded, 'hex'), }); assert(signedMessageRequest.messages, 'Unable to find messages in signedMessageRequest'); assert( @@ -3603,8 +3653,8 @@ export class Wallet implements IWallet { coin: this.coin(), txHash: signedMessageRequest.messages[0].txHash, signature: signedMessageRequest.messages[0].txHash, - messageRaw: params.message?.messageRaw, - messageEncoded: params.message?.messageEncoded, + messageRaw, + messageEncoded, txRequestId: signedMessageRequest.txRequestId, }; } catch (e) {