diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index b37ffb4b866..034b8695e1e 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -4517,6 +4517,7 @@ describe('V2 Wallet:', function () { let signTxRequestForMessage; const messageSigningCoins = ['teth', 'tpolygon']; const expected: SignedMessage = { + state: 'delivered', txRequestId: reqId.toString(), txHash, signature: txHash, @@ -4696,6 +4697,7 @@ describe('V2 Wallet:', function () { ], }; const solExpectedSignedMessage: SignedMessage = { + state: 'delivered', txRequestId: reqId.toString(), txHash: solTxHash, signature: solTxHash, @@ -4999,6 +5001,7 @@ describe('V2 Wallet:', function () { const txRequestId = txRequestForTypedDataSigning.txRequestId; typedDataBase.txRequestId = txRequestId; const expected: SignedMessage = { + state: 'delivered', txRequestId, messageRaw: JSON.stringify(typedMessage), signature: txHash, @@ -5147,6 +5150,230 @@ describe('V2 Wallet:', function () { }); }); + describe('Message Signing with pending approval (policy flow)', function () { + const messageRaw = 'hello world'; + const messageEncoded = Buffer.from(`\u0019Ethereum Signed Message:\n${messageRaw.length}${messageRaw}`).toString( + 'hex' + ); + const txHash = '0xrrrsss1b'; + const pendingApprovalId = 'pa-11112222333344445555666677778888'; + + const signedMessagesFixture: NonNullable = [ + { + state: 'signed', + messageRaw, + derivationPath: 'm/0', + signatureShares: [{ from: SignatureShareType.USER, to: SignatureShareType.USER, share: '' }], + combineSigShare: '0:rrr:sss:3', + txHash, + messageEncoded, + }, + ]; + + const parkedTxRequest: TxRequest = { + txRequestId: 'parked-tx-request-id', + transactions: [], + intent: { intentType: 'signTypedStructuredData' }, + date: new Date().toISOString(), + latest: true, + state: 'pendingApproval', + apiVersion: 'full', + pendingApprovalId, + userId: 'userId', + walletType: 'hot', + policiesChecked: true, + version: 1, + walletId: 'walletId', + unsignedTxs: [], + unsignedMessages: [], + messages: [ + { + state: 'pendingSignature', + messageRaw, + derivationPath: 'm/0', + signatureShares: [], + messageEncoded, + }, + ], + }; + + const signableTxRequest: TxRequest = { + ...parkedTxRequest, + state: 'pendingDelivery', + }; + + const signedTxRequest: TxRequest = { + ...parkedTxRequest, + state: 'delivered', + messages: signedMessagesFixture, + }; + + const typedDataFixture: TypedData = { + typedDataRaw: JSON.stringify({ + domain: { + name: 'bitgo', + version: '1', + chainId: 1, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + primaryType: 'Message', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Message: [{ name: 'data', type: 'string' }], + }, + message: { data: 'bitgo says hello!' }, + }), + version: SignTypedDataVersion.V3, + }; + + let signTxRequestForMessageStub: sinon.SinonStub; + + beforeEach(function () { + signTxRequestForMessageStub = sandbox.stub(ECDSAUtils.EcdsaUtils.prototype, 'signTxRequestForMessage'); + signTxRequestForMessageStub.resolves(signedTxRequest); + sandbox.stub(Keychains.prototype, 'getKeysForSigning').resolves([ + { + commonKeychain: 'test', + id: '', + pub: '', + type: 'tss', + encryptedPrv: + '{"iv":"15FsbDVI1zG9OggD8YX+Hg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"hHbNH3Sz/aU=","ct":"WoNVKz7afiRxXI2w/YkzMdMyoQg/B15u1Q8aQgi96jJZ9wk6TIaSEc6bXFH3AHzD9MdJCWJQUpRhoQc/rgytcn69scPTjKeeyVMElGCxZdFVS/psQcNE+lue3//2Zlxj+6t1NkvYO+8yAezSMRBK5OdftXEjNQI="}', + }, + ]); + }); + + /** + * Narrows the SignedMessage union after asserting its state: a should + * assertion on `state` alone does not narrow the discriminated union + * for the compiler, so variant-specific fields must be read from the + * narrowed return value. + */ + function assertSignedMessageState( + result: SignedMessage, + state: T + ): Extract { + result.state.should.equal(state); + return result as Extract; + } + + afterEach(function () { + sandbox.restore(); + nock.cleanAll(); + }); + + it('should return pendingApprovalId without signing when a fresh signMessage request is parked', async function () { + nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/msgrequests`).reply(200, parkedTxRequest); + + const result = await tssEthWallet.signMessage({ + message: { messageRaw, messageStandardType: MessageStandardType.EIP191 }, + prv: 'secretKey', + }); + + const parked = assertSignedMessageState(result, 'pendingApproval'); + parked.pendingApprovalId.should.equal(pendingApprovalId); + parked.txRequestId.should.equal(parkedTxRequest.txRequestId); + sinon.assert.notCalled(signTxRequestForMessageStub); + }); + + it('should return pendingApprovalId without signing when resuming a parked signMessage request', async function () { + nock(bgUrl) + .get(`/api/v2/wallet/${tssEthWallet.id()}/txrequests?txRequestIds=${parkedTxRequest.txRequestId}&latest=true`) + .reply(200, { txRequests: [parkedTxRequest] }); + + const result = await tssEthWallet.signMessage({ + message: { + messageRaw, + txRequestId: parkedTxRequest.txRequestId, + messageStandardType: MessageStandardType.EIP191, + }, + prv: 'secretKey', + }); + + const parked = assertSignedMessageState(result, 'pendingApproval'); + parked.pendingApprovalId.should.equal(pendingApprovalId); + }); + + it('should return pendingApprovalId without signing when a fresh signTypedData request is parked', async function () { + nock(bgUrl).post(`/api/v2/wallet/${tssEthWallet.id()}/txrequests`).reply(200, parkedTxRequest); + + const result = await tssEthWallet.signTypedData({ + typedData: typedDataFixture, + prv: 'secretKey', + }); + + const parked = assertSignedMessageState(result, 'pendingApproval'); + parked.pendingApprovalId.should.equal(pendingApprovalId); + sinon.assert.notCalled(signTxRequestForMessageStub); + }); + + it('should return pendingApprovalId without signing when resuming a parked signTypedData request', async function () { + nock(bgUrl) + .get(`/api/v2/wallet/${tssEthWallet.id()}/txrequests?txRequestIds=${parkedTxRequest.txRequestId}&latest=true`) + .reply(200, { txRequests: [parkedTxRequest] }); + + const result = await tssEthWallet.signTypedData({ + typedData: { ...typedDataFixture, txRequestId: parkedTxRequest.txRequestId }, + prv: 'secretKey', + }); + + const parked = assertSignedMessageState(result, 'pendingApproval'); + parked.pendingApprovalId.should.equal(pendingApprovalId); + sinon.assert.notCalled(signTxRequestForMessageStub); + }); + + it('should sign a pendingDelivery message request via signAndSendMessageTxRequest', async function () { + nock(bgUrl) + .get( + `/api/v2/wallet/${tssEthWallet.id()}/txrequests?txRequestIds=${signableTxRequest.txRequestId}&latest=true` + ) + .reply(200, { txRequests: [signableTxRequest] }); + + const result = await tssEthWallet.signAndSendMessageTxRequest({ + txRequestId: signableTxRequest.txRequestId, + walletPassphrase: TestBitGo.V2.TEST_ETH_WALLET_PASSPHRASE as string, + }); + + const delivered = assertSignedMessageState(result, 'delivered'); + delivered.txHash.should.equal(txHash); + delivered.signature.should.equal(txHash); + delivered.txRequestId.should.equal(signableTxRequest.txRequestId); + delivered.messageRaw.should.equal(messageRaw); + sinon.assert.calledOnce(signTxRequestForMessageStub); + }); + + it('should return pendingApprovalId without signing when signAndSendMessageTxRequest hits a still-parked request', async function () { + nock(bgUrl) + .get(`/api/v2/wallet/${tssEthWallet.id()}/txrequests?txRequestIds=${parkedTxRequest.txRequestId}&latest=true`) + .reply(200, { txRequests: [parkedTxRequest] }); + + const result = await tssEthWallet.signAndSendMessageTxRequest({ + txRequestId: parkedTxRequest.txRequestId, + walletPassphrase: TestBitGo.V2.TEST_ETH_WALLET_PASSPHRASE as string, + }); + + const parked = assertSignedMessageState(result, 'pendingApproval'); + parked.pendingApprovalId.should.equal(pendingApprovalId); + sinon.assert.notCalled(signTxRequestForMessageStub); + }); + + it('should reject signAndSendMessageTxRequest for non-TSS wallets', async function () { + const nonTssWallet = new Wallet(bitgo, bitgo.coin('teth'), { ...ethWalletData, multisigType: 'onchain' }); + + await nonTssWallet + .signAndSendMessageTxRequest({ + txRequestId: parkedTxRequest.txRequestId, + walletPassphrase: 'passphrase', + }) + .should.be.rejectedWith('Message signing only supported for TSS wallets'); + }); + }); + describe('Send Many', function () { const sendManyInput = { type: 'transfer', diff --git a/modules/sdk-coin-xdc/src/lib/xdcKycMessage.ts b/modules/sdk-coin-xdc/src/lib/xdcKycMessage.ts index 3dbece732a4..2a152c9737f 100644 --- a/modules/sdk-coin-xdc/src/lib/xdcKycMessage.ts +++ b/modules/sdk-coin-xdc/src/lib/xdcKycMessage.ts @@ -44,6 +44,16 @@ export async function signXdcKycMessage( walletPassphrase, }); + // A message-signing policy can park the sign request behind a pending + // approval (no signature is produced); the KYC flow requires a signature, + // so fail loudly instead of returning an unusable signed-message object. + if (signed.state === 'pendingApproval') { + throw new Error( + `Unable to sign XDC KYC message: the sign request is parked behind pending approval (${signed.pendingApprovalId}). ` + + `Approve the pending approval, then retry the sign request.` + ); + } + return { kycAccount: account, kycMessage, diff --git a/modules/sdk-coin-xdc/test/unit/xdcKycMessage.ts b/modules/sdk-coin-xdc/test/unit/xdcKycMessage.ts index 6cb93f2397a..56ddf4cfb83 100644 --- a/modules/sdk-coin-xdc/test/unit/xdcKycMessage.ts +++ b/modules/sdk-coin-xdc/test/unit/xdcKycMessage.ts @@ -36,6 +36,7 @@ describe('signXdcKycMessage', function () { it('should call wallet.signMessage with correct messageRaw and messageStandardType', async function () { (wallet.signMessage as sinon.SinonStub).resolves({ + state: 'delivered', txHash: '', signature: SIGNATURE, messageRaw: '', @@ -54,6 +55,7 @@ describe('signXdcKycMessage', function () { it('should return kycAccount, kycMessage, and kycSignature mapped correctly', async function () { (wallet.signMessage as sinon.SinonStub).resolves({ + state: 'delivered', txHash: '', signature: SIGNATURE, messageRaw: '', @@ -79,4 +81,17 @@ describe('signXdcKycMessage', function () { /TSS signing failed/ ); }); + + it('should reject when the sign request is parked behind a pending approval', async function () { + (wallet.signMessage as sinon.SinonStub).resolves({ + state: 'pendingApproval', + messageRaw: '', + pendingApprovalId: 'pa-11112222333344445555666677778888', + }); + + await assert.rejects( + () => signXdcKycMessage(wallet as unknown as IWallet, ACCOUNT, 'passphrase'), + /pending approval \(pa-11112222333344445555666677778888\)/ + ); + }); }); diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 2e6b1eaa025..c79e0e53e0a 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -598,17 +598,49 @@ export type SignedTransaction = | SignedTransactionRequest | TxRequest; -export interface SignedMessage { +/** + * Base fields shared by every message-sign result. + */ +export interface SignedMessageBase { coin?: string; - // @deprecated - use `signature` instead - txHash: string; - signature: string; messageRaw: string; messageEncoded?: string; messageStandardType?: MessageStandardType; txRequestId: string; } +/** + * The message-sign request was parked behind a pending approval by the + * message-signing policy; no signature was produced. The discriminator is the + * server-reported txRequest state ('pendingApproval') — never inferred from + * id presence. Surface the approval, then re-sign via + * signAndSendMessageTxRequest once it is resolved. + */ +export interface SignedMessagePendingApproval extends SignedMessageBase { + state: 'pendingApproval'; + pendingApprovalId: string; +} + +/** + * The message is signed and the request is terminal (wallet-platform + * transitions a message request pendingDelivery → delivered once all + * messages are signed; the txRequest-level 'signed' state is never used for + * message requests). + */ +export interface SignedMessageDelivered extends SignedMessageBase { + state: 'delivered'; + // @deprecated - use `signature` instead + txHash: string; + signature: string; +} + +/** + * Discriminated on the wallet-platform txRequest state. BREAKING: consumers + * that previously read `txHash`/`signature` without narrowing must first + * check `state === 'delivered'`. + */ +export type SignedMessage = SignedMessagePendingApproval | SignedMessageDelivered; + export interface RecoverWalletTokenOptions { tokenContractAddress: string; wallet: IWallet; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index e0130c883b3..11f553e3863 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -451,6 +451,12 @@ export interface SignAndSendTxRequestOptions { isTxRequestFull: boolean; } +export interface SignAndSendMessageTxRequestOptions { + txRequestId: string; + walletPassphrase: string; + reqId?: IRequestTracer; +} + export interface GetUserPrvOptions { keychain?: Keychain; key?: Keychain; @@ -1371,6 +1377,7 @@ export interface IWallet { signMessage(params: WalletSignMessageOptions): Promise; buildSignMessageRequest(params: WalletSignMessageOptions): Promise; signTypedData(params: WalletSignTypedDataOptions): Promise; + signAndSendMessageTxRequest(params: SignAndSendMessageTxRequestOptions): Promise; fetchCrossChainUTXOs(params: FetchCrossChainUTXOsOptions): Promise; getChallengesForEcdsaSigning(): Promise; getNftBalances(): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 68b7d8e0daa..930b1cf5f27 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -125,6 +125,7 @@ import { SharedKeyChain, ShareWalletOptions, SignAndSendTxRequestOptions, + SignAndSendMessageTxRequestOptions, SimulateWebhookOptions, SubmitTransactionOptions, SubWalletType, @@ -5087,6 +5088,93 @@ export class Wallet implements IWallet { return ret; } + /** + * Signs the message of a full (apiVersion === 'full') message-sign + * transaction request from a TSS (hot) wallet. Meant to be used for a + * message-sign request whose signing was parked behind a pending approval + * by the message-signing policy and resumed after the approval was + * resolved. Message-sign transaction requests are full-only: the lite + * (deprecated) request flow never carries messages. + * + * Unlike signAndSendTxRequest, this signs through the message path + * (signTxRequestForMessage with the WP-persisted messageEncoded); the + * transaction signer has no transaction to sign on a message-only request. + * The method only signs — delivery of the signature to the requestor is + * completed server-side once the signature shares are ingested. + * + * If the request is still parked behind a pending approval (txRequest + * state 'pendingApproval'), resolves with the pendingApprovalId instead + * of signing (same contract as signMessage/signTypedData). + * + * @param params + * txRequestId - The ID of the message-sign transaction request. + * walletPassphrase - The passphrase for the wallet. + * reqId - Optional request tracer. + * @returns A promise that resolves to a SignedMessage. + */ + public async signAndSendMessageTxRequest(params: SignAndSendMessageTxRequestOptions): Promise { + if (this._wallet.multisigType !== 'tss') { + throw new Error('Message signing only supported for TSS wallets'); + } + const reqId = params.reqId ?? new RequestTracer(); + this.bitgo.setRequestTracer(reqId); + + const keychains = await this.getKeychainsAndValidatePassphrase({ + reqId, + walletPassphrase: params.walletPassphrase, + }); + const userKeychain = keychains[0]; + if (!userKeychain || !userKeychain.encryptedPrv) { + throw new Error('the user keychain does not have property encryptedPrv'); + } + + const txRequest = await getTxRequest(this.bitgo, this.id(), params.txRequestId, reqId); + + // The message-signing policy may still park the request behind a pending + // approval; resolve with the pendingApprovalId instead of signing. + if (this.tssUtils?.isPendingApprovalTxRequestFull(txRequest)) { + assert(txRequest.pendingApprovalId, 'Unable to find pendingApprovalId on a pendingApproval-state txRequest'); + return { + state: 'pendingApproval', + coin: this.coin(), + messageRaw: txRequest.messages?.[0]?.messageRaw ?? '', + txRequestId: txRequest.txRequestId, + pendingApprovalId: txRequest.pendingApprovalId, + }; + } + + assert( + txRequest.messages && txRequest.messages.length > 0, + 'Unable to find messages in txRequest for message signing' + ); + const message = txRequest.messages[0]; + const messageEncoded = message.messageEncoded; + assert(messageEncoded, 'Unable to find messageEncoded in txRequest message'); + + const signedMessageRequest = await this.tssUtils!.signTxRequestForMessage({ + txRequest, + prv: await this.getUserPrv({ + keychain: userKeychain, + walletPassphrase: params.walletPassphrase, + }), + reqId, + messageRaw: message.messageRaw, + messageEncoded, + bufferToSign: Buffer.from(messageEncoded, 'hex'), + }); + assert(signedMessageRequest.messages, 'Unable to find messages in signedMessageRequest'); + assert(signedMessageRequest.messages[0].txHash, 'Unable to find txHash in signedMessageRequest.messages'); + return { + state: 'delivered', + coin: this.coin(), + txHash: signedMessageRequest.messages[0].txHash, + signature: signedMessageRequest.messages[0].txHash, + messageRaw: message.messageRaw, + messageEncoded, + txRequestId: signedMessageRequest.txRequestId, + }; + } + /** * Ensures signature shares are in a clean state before signing a transaction. * Automatically deletes signature shares for Full TxRequests before signing to prevent @@ -5341,6 +5429,20 @@ export class Wallet implements IWallet { txRequest = await getTxRequest(this.bitgo, this.id(), params.message.txRequestId, params.reqId); } + // A message-signing policy can park the request behind a pending approval. + // Signing cannot proceed until the approval is resolved; surface the + // pendingApprovalId instead of attempting MPC rounds against a parked request + // (which would fail at send with a pendingDelivery state error). + if (this.tssUtils!.isPendingApprovalTxRequestFull(txRequest)) { + return { + state: 'pendingApproval', + coin: this.coin(), + messageRaw, + txRequestId: txRequest.txRequestId, + pendingApprovalId: txRequest.pendingApprovalId, + }; + } + assert( txRequest.messages && txRequest.messages.length > 0, 'Unable to find messages in txRequest for message signing' @@ -5362,20 +5464,25 @@ export class Wallet implements IWallet { 'Unable to find combineSigShare in signedMessageRequest.messages' ); } - assert(signedMessageRequest.messages[0].txHash, 'Unable to find txHash in signedMessageRequest.messages'); + const { txHash } = signedMessageRequest.messages[0]; + assert(txHash, 'Unable to find txHash in signedMessageRequest.messages'); // messageRaw on the signed request is the WP-persisted payload that was validated and // encoded before signing; the caller's echo is unverifiable on the resume path. const signedMessageRaw = signedMessageRequest.messages[0].messageRaw ?? messageRaw; return { + state: 'delivered', coin: this.coin(), - txHash: signedMessageRequest.messages[0].txHash, - signature: signedMessageRequest.messages[0].txHash, + txHash, + signature: txHash, messageRaw: signedMessageRaw, messageEncoded, txRequestId: signedMessageRequest.txRequestId, }; } catch (e) { - throw new Error('failed to sign message ' + e); + // Rethrow the original error so callers can classify it (e.g. the 409 + // TxRequestPendingApprovalError from wallet-platform) instead of losing + // the error type inside a generic message. + throw e; } } @@ -5410,6 +5517,19 @@ export class Wallet implements IWallet { txRequest = await getTxRequest(this.bitgo, this.id(), params.typedData.txRequestId, params.reqId); } + // A message-signing policy can park the request behind a pending approval. + // Signing cannot proceed until the approval is resolved; surface the + // pendingApprovalId instead of attempting MPC rounds against a parked request. + if (this.tssUtils!.isPendingApprovalTxRequestFull(txRequest)) { + return { + state: 'pendingApproval', + coin: this.coin(), + messageRaw: params.typedData.typedDataRaw, + txRequestId: txRequest.txRequestId, + pendingApprovalId: txRequest.pendingApprovalId, + }; + } + const signedTypedDataRequest = await this.tssUtils!.signTxRequestForMessage({ txRequest, prv: params.prv, @@ -5423,17 +5543,22 @@ export class Wallet implements IWallet { signedTypedDataRequest.messages[0].combineSigShare, 'Unable to find combineSigShare in signedTypedDataRequest.messages' ); - assert(signedTypedDataRequest.messages[0].txHash, 'Unable to find txHash in signedTypedDataRequest.messages'); + const { txHash } = signedTypedDataRequest.messages[0]; + assert(txHash, 'Unable to find txHash in signedTypedDataRequest.messages'); return { + state: 'delivered', coin: this.coin(), - txHash: signedTypedDataRequest.messages[0].txHash, - signature: signedTypedDataRequest.messages[0].txHash, + txHash, + signature: txHash, messageRaw: params.typedData.typedDataRaw, messageEncoded: params.typedData.typedDataEncoded!.toString('hex'), txRequestId: signedTypedDataRequest.txRequestId, }; } catch (e) { - throw new Error('failed to sign typed data ' + e); + // Rethrow the original error so callers can classify it (e.g. the 409 + // TxRequestPendingApprovalError from wallet-platform) instead of losing + // the error type inside a generic message. + throw e; } }