diff --git a/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts b/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts index 7791b16372..92d811360a 100644 --- a/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts +++ b/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts @@ -1,6 +1,5 @@ import { TransactionType } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; -import { fromBase64 } from '@cosmjs/encoding'; import * as constants from './constants'; import { CosmosTransactionMessage, ExecuteContractMessage, MessageData } from './iface'; @@ -20,32 +19,24 @@ export class ContractCallBuilder extends CosmosTransactio } /** @inheritdoc */ - messages(messages: (CosmosTransactionMessage | MessageData)[]): this { + messages(messages: (CosmosTransactionMessage | Partial>)[]): this { this._messages = messages.map((message) => { - const msg = message as MessageData; - const { typeUrl, value } = msg; - - // Handle pre-encoded messages (base64 string input) - if (typeUrl && typeof value === 'string') { - try { - return { typeUrl, value: fromBase64(value) } as MessageData; - } catch (err: unknown) { - throw new Error(`Invalid base64 string in message value: ${String(err)}`); - } + const executeContractMessage = message as ExecuteContractMessage; + + if (!executeContractMessage.msg) { + // Pre-encoded message from deserialization round-trip + return message as MessageData; } - // Handle already-encoded messages (Uint8Array from deserialization) - if (typeUrl && value instanceof Uint8Array) { - return { typeUrl, value } as MessageData; + if (CosmosUtils.isGroupProposal(executeContractMessage)) { + return { + typeUrl: constants.groupProposalMsgTypeUrl, + value: executeContractMessage.msg, + } as MessageData; } - // Handle typed ExecuteContractMessage - const executeContractMessage = message as ExecuteContractMessage; this._utils.validateExecuteContractMessage(executeContractMessage, this.transactionType); - return { - typeUrl: constants.executeContractMsgTypeUrl, - value: executeContractMessage, - }; + return { typeUrl: constants.executeContractMsgTypeUrl, value: executeContractMessage }; }); return this; } diff --git a/modules/abstract-cosmos/src/lib/utils.ts b/modules/abstract-cosmos/src/lib/utils.ts index dc6820e443..cdbedda083 100644 --- a/modules/abstract-cosmos/src/lib/utils.ts +++ b/modules/abstract-cosmos/src/lib/utils.ts @@ -998,6 +998,70 @@ export class CosmosUtils implements BaseUtils { ), ]; } + + /** + * Checks if an ExecuteContractMessage's msg field contains a group proposal. + * @param {ExecuteContractMessage} message - The execute contract message to check + * @returns {boolean} true if the msg decodes to a group proposal + */ + static isGroupProposal(message: ExecuteContractMessage): boolean { + if (!message.msg || message.msg.length === 0) { + return false; + } + const result = CosmosUtils.decodeMsg(message.msg); + return result.typeUrl === constants.groupProposalMsgTypeUrl; + } + + /** + * Decodes a protobuf message and determines its type. + * + * @param data - Message data as base64 string or Uint8Array + * @returns Decoded message result with typeUrl if successfully identified + */ + static decodeMsg(data: string | Uint8Array): { typeUrl?: string; error?: string } { + try { + const messageBytes = typeof data === 'string' ? Buffer.from(data, 'base64') : data; + + try { + const proposal = MsgSubmitProposal.decode(messageBytes); + if ( + proposal.groupPolicyAddress && + typeof proposal.groupPolicyAddress === 'string' && + proposal.groupPolicyAddress.length > 0 && + Array.isArray(proposal.proposers) && + proposal.proposers.length > 0 + ) { + return { typeUrl: constants.groupProposalMsgTypeUrl }; + } + } catch { + // Not a group proposal + } + + try { + const executeMsg = MsgExecuteContract.decode(messageBytes); + if ( + executeMsg.sender && + typeof executeMsg.sender === 'string' && + executeMsg.sender.length > 0 && + executeMsg.contract && + typeof executeMsg.contract === 'string' && + executeMsg.contract.length > 0 && + executeMsg.msg instanceof Uint8Array && + executeMsg.msg.length > 0 + ) { + return { typeUrl: constants.executeContractMsgTypeUrl }; + } + } catch { + // Not an execute contract message + } + + return { error: 'Unable to decode message as any known type' }; + } catch (error) { + return { + error: error instanceof Error ? error.message : 'Failed to decode message', + }; + } + } } const utils = new CosmosUtils(); diff --git a/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts b/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts index e6cf78b25a..d178a1e226 100644 --- a/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts +++ b/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts @@ -4,7 +4,7 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { fromBase64, toHex } from '@cosmjs/encoding'; import should from 'should'; import { Hash, Thash } from '../../../src'; -import { TEST_CONTRACT_CALL } from '../../resources/hash'; +import { TEST_CONTRACT_CALL, testnetAddress } from '../../resources/hash'; describe('Hash ContractCall Builder', () => { let bitgo: TestBitGoAPI; @@ -24,17 +24,12 @@ describe('Hash ContractCall Builder', () => { txBuilder.feeGranter(TEST_CONTRACT_CALL.feeGranter); txBuilder.publicKey(toHex(fromBase64(TEST_CONTRACT_CALL.pubKey))); - // Wrap the inner message in a group proposal - // const wrappedMessage = wrapInGroupProposal( - // TEST_CONTRACT_CALL.preEncodedMessageValue, - // TEST_CONTRACT_CALL.proposer, - // testnetAddress.groupPolicyAddress - // ); - txBuilder.messages([ { - typeUrl: '/cosmos.group.v1.MsgSubmitProposal', - value: TEST_CONTRACT_CALL.encodedProposal, + sender: TEST_CONTRACT_CALL.proposer, + contract: testnetAddress.groupPolicyAddress, + msg: fromBase64(TEST_CONTRACT_CALL.encodedProposal), + funds: [], }, ]); return txBuilder; diff --git a/modules/sdk-coin-hash/test/unit/utils.ts b/modules/sdk-coin-hash/test/unit/utils.ts index 22f1bff59d..dffca74a70 100644 --- a/modules/sdk-coin-hash/test/unit/utils.ts +++ b/modules/sdk-coin-hash/test/unit/utils.ts @@ -1,8 +1,9 @@ import should from 'should'; +import { CosmosUtils } from '@bitgo/abstract-cosmos'; import utils from '../../src/lib/utils'; import * as testData from '../resources/hash'; -import { blockHash, txIds } from '../resources/hash'; +import { blockHash, txIds, TEST_CONTRACT_CALL } from '../resources/hash'; describe('utils', () => { it('should validate block hash correctly', () => { @@ -44,4 +45,75 @@ describe('utils', () => { 'transactionBuilder: validateAmount: Invalid denom: ' + testData.coinAmounts.amount5.denom ); }); + + describe('decodeMsg', () => { + it('should detect valid base64-encoded group proposal', () => { + const result = CosmosUtils.decodeMsg(TEST_CONTRACT_CALL.encodedProposal); + + should.exist(result.typeUrl); + if (result.typeUrl) { + result.typeUrl.should.equal('/cosmos.group.v1.MsgSubmitProposal'); + } + should.not.exist(result.error); + }); + + it('should reject invalid base64 string', () => { + const result = CosmosUtils.decodeMsg('not-valid-base64!!!'); + + should.not.exist(result.typeUrl); + should.exist(result.error); + }); + + it('should reject valid base64 but invalid protobuf', () => { + const result = CosmosUtils.decodeMsg(Buffer.from('random data').toString('base64')); + + should.not.exist(result.typeUrl); + should.exist(result.error); + }); + + it('should reject hex-encoded contract call data', () => { + const result = CosmosUtils.decodeMsg('7b22696e6372656d656e74223a7b7d7d'); + + should.not.exist(result.typeUrl); + }); + + it('should accept Uint8Array input', () => { + const bytes = Buffer.from(TEST_CONTRACT_CALL.encodedProposal, 'base64'); + const result = CosmosUtils.decodeMsg(bytes); + + should.exist(result.typeUrl); + if (result.typeUrl) { + result.typeUrl.should.equal('/cosmos.group.v1.MsgSubmitProposal'); + } + }); + }); + + describe('isGroupProposal', () => { + it('should return true when msg contains a group proposal', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: Buffer.from(TEST_CONTRACT_CALL.encodedProposal, 'base64'), + }; + should.equal(CosmosUtils.isGroupProposal(message), true); + }); + + it('should return false when msg contains regular contract call data', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: Buffer.from(JSON.stringify({ increment: {} })), + }; + should.equal(CosmosUtils.isGroupProposal(message), false); + }); + + it('should return false when msg is empty', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: new Uint8Array(0), + }; + should.equal(CosmosUtils.isGroupProposal(message), false); + }); + }); });