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
227 changes: 227 additions & 0 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4696,6 +4697,7 @@ describe('V2 Wallet:', function () {
],
};
const solExpectedSignedMessage: SignedMessage = {
state: 'delivered',
txRequestId: reqId.toString(),
txHash: solTxHash,
signature: solTxHash,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<TxRequest['messages']> = [
{
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<T extends SignedMessage['state']>(
result: SignedMessage,
state: T
): Extract<SignedMessage, { state: T }> {
result.state.should.equal(state);
return result as Extract<SignedMessage, { state: T }>;
}

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',
Expand Down
11 changes: 11 additions & 0 deletions modules/sdk-coin-xdc/src/lib/xdcKycMessage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import assert from 'assert';
import { IWallet, MessageStandardType } from '@bitgo/sdk-core';

export interface XdcKycMessageParams {
Expand Down Expand Up @@ -44,6 +45,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 pending approval (${signed.pendingApprovalId})`
);
}
assert(signed.signature, 'Unable to sign XDC KYC message: no signature was produced');

return {
kycAccount: account,
kycMessage,
Expand Down
15 changes: 15 additions & 0 deletions modules/sdk-coin-xdc/test/unit/xdcKycMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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: '',
Expand All @@ -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\)/
);
});
});
40 changes: 36 additions & 4 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +633 to +634

@zahin-mohammad zahin-mohammad Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is still a breaking change, these fields only exist on one arm of the union type, meaning clients that used to rely on SignedMessage.txHash or SignedMessage.signature will receive a compilation error after they upgrade to a sdk version with this change.

}

/**
* 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;
Expand Down
7 changes: 7 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1371,6 +1377,7 @@ export interface IWallet {
signMessage(params: WalletSignMessageOptions): Promise<SignedMessage>;
buildSignMessageRequest(params: WalletSignMessageOptions): Promise<TxRequest>;
signTypedData(params: WalletSignTypedDataOptions): Promise<SignedMessage>;
signAndSendMessageTxRequest(params: SignAndSendMessageTxRequestOptions): Promise<SignedMessage>;
fetchCrossChainUTXOs(params: FetchCrossChainUTXOsOptions): Promise<CrossChainUTXO[]>;
getChallengesForEcdsaSigning(): Promise<WalletEcdsaChallenges>;
getNftBalances(): Promise<NftBalance[]>;
Expand Down
Loading
Loading