From 26177417ebce09fe4c73196a9e46eeaa5c6b5eac Mon Sep 17 00:00:00 2001 From: Prajwal U Date: Wed, 10 Jun 2026 23:29:53 +0530 Subject: [PATCH] feat(abstract-cosmos): support execType for Cosmos group proposal execution mode (CSHLD-972) Adds execType field to CosmosLikeTransaction and threads it through the builder chain so callers can control whether a group.v1 MsgSubmitProposal executes immediately (EXEC_TRY=1) or defers to chain governance queue (EXEC_UNSPECIFIED=0). The field is applied only during getSendMessagesForEncodingTx when the message typeUrl matches groupProposalMsgTypeUrl, leaving all other Cosmos chains and message types unaffected. Co-Authored-By: Claude Sonnet 4.6 TICKET: CSHLD-972 --- modules/abstract-cosmos/src/lib/iface.ts | 1 + .../src/lib/transactionBuilder.ts | 12 ++- modules/abstract-cosmos/src/lib/utils.ts | 12 ++- .../transactionBuilder/transactionBuilder.ts | 87 +++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/modules/abstract-cosmos/src/lib/iface.ts b/modules/abstract-cosmos/src/lib/iface.ts index c21d1456b7..c0765cfc75 100644 --- a/modules/abstract-cosmos/src/lib/iface.ts +++ b/modules/abstract-cosmos/src/lib/iface.ts @@ -110,6 +110,7 @@ export interface CosmosLikeTransaction { readonly signature?: Uint8Array; readonly hash?: string; readonly memo?: string; + readonly execType?: string; } export interface KeyShares { diff --git a/modules/abstract-cosmos/src/lib/transactionBuilder.ts b/modules/abstract-cosmos/src/lib/transactionBuilder.ts index bb57666ea0..b7bfd444a6 100644 --- a/modules/abstract-cosmos/src/lib/transactionBuilder.ts +++ b/modules/abstract-cosmos/src/lib/transactionBuilder.ts @@ -30,6 +30,7 @@ export abstract class CosmosTransactionBuilder extends Ba protected _memo?: string; protected _utils: CosmosUtils; + protected _execType?: string; constructor(_coinConfig: Readonly, _utils: CosmosUtils) { super(_coinConfig); @@ -158,6 +159,11 @@ export abstract class CosmosTransactionBuilder extends Ba return this; } + execType(value: string): this { + this._execType = value; + return this; + } + /** * Initialize the transaction builder fields using the decoded transaction data * @param {CosmosTransaction} tx the transaction data @@ -214,7 +220,8 @@ export abstract class CosmosTransactionBuilder extends Ba this._messages, this._gasBudget, this._publicKey, - this._memo + this._memo, + this._execType ); const privateKey = this._signer?.getPrivateKey(); @@ -238,7 +245,8 @@ export abstract class CosmosTransactionBuilder extends Ba this._gasBudget, this._publicKey, this._signature, - this._memo + this._memo, + this._execType ); } this.transaction.loadInputsAndOutputs(); diff --git a/modules/abstract-cosmos/src/lib/utils.ts b/modules/abstract-cosmos/src/lib/utils.ts index b3e9809826..b243af595d 100644 --- a/modules/abstract-cosmos/src/lib/utils.ts +++ b/modules/abstract-cosmos/src/lib/utils.ts @@ -416,6 +416,9 @@ export class CosmosUtils implements BaseUtils { // For group proposal/vote messages, the pre-encoded bytes contain the full message try { const decoded = this.registry.decode({ typeUrl: msg.typeUrl, value: msg.value }); + if (cosmosLikeTransaction.execType !== undefined && msg.typeUrl === constants.groupProposalMsgTypeUrl) { + decoded.exec = cosmosLikeTransaction.execType === 'EXEC_TRY' ? 1 : 0; + } return { typeUrl: msg.typeUrl, value: decoded, @@ -737,7 +740,8 @@ export class CosmosUtils implements BaseUtils { messages: MessageData[], gasBudget: FeeData, publicKey?: string, - memo?: string + memo?: string, + execType?: string ): CosmosLikeTransaction { const cosmosLikeTxn = { sequence: sequence, @@ -745,6 +749,7 @@ export class CosmosUtils implements BaseUtils { gasBudget: gasBudget, publicKey: publicKey, memo: memo, + execType: execType, }; this.validateTransaction(cosmosLikeTxn); return cosmosLikeTxn; @@ -766,9 +771,10 @@ export class CosmosUtils implements BaseUtils { gasBudget: FeeData, publicKey?: string, signature?: Buffer, - memo?: string + memo?: string, + execType?: string ): CosmosLikeTransaction { - const cosmosLikeTxn = this.createTransaction(sequence, messages, gasBudget, publicKey, memo); + const cosmosLikeTxn = this.createTransaction(sequence, messages, gasBudget, publicKey, memo, execType); let hash = constants.UNAVAILABLE_TEXT; if (signature !== undefined) { const unsignedTx = this.createTxRawFromCosmosLikeTransaction(cosmosLikeTxn); diff --git a/modules/sdk-coin-hash/test/unit/transactionBuilder/transactionBuilder.ts b/modules/sdk-coin-hash/test/unit/transactionBuilder/transactionBuilder.ts index 56b85fadd3..32bb0c9f38 100644 --- a/modules/sdk-coin-hash/test/unit/transactionBuilder/transactionBuilder.ts +++ b/modules/sdk-coin-hash/test/unit/transactionBuilder/transactionBuilder.ts @@ -3,6 +3,7 @@ import should from 'should'; import { BitGoAPI } from '@bitgo/sdk-api'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; +import { fromBase64, toHex } from '@cosmjs/encoding'; import { Hash, Thash } from '../../../src'; import * as testData from '../../resources/hash'; @@ -111,4 +112,90 @@ describe('Hash Transaction Builder', async () => { ); } }); + + /** + * Tests for the execType setter on CosmosTransactionBuilder. + * execType is used with MsgSubmitProposal group proposals to control how the proposal + * is executed — EXEC_TRY (exec=1) causes immediate execution, EXEC_UNSPECIFIED (exec=0) does not. + * + * To avoid message-validation side-effects from the proposal-decoding path in messages(), + * we use the pre-encoded MessageData route: factory.from() deserializes an existing + * MsgSubmitProposal tx, which stores sendMessages[0].value as a Uint8Array. That Uint8Array + * is then forwarded directly to a new ContractCallBuilder. When ContractCallBuilder.messages() + * receives an object without a `.msg` field it treats it as a pre-encoded MessageData and + * skips all address / proposal-type validation, keeping the test focused on execType behaviour. + */ + describe('execType tests', async () => { + /** + * Returns the pre-encoded MessageData extracted from a round-trip through factory.from() + * on the known TEST_SUBMIT_PROPOSAL tx. The deserialized sendMessages[0] has + * { typeUrl: '/cosmos.group.v1.MsgSubmitProposal', value: Uint8Array } which bypasses + * the isGroupProposal / validateExecuteContractMessage validation in ContractCallBuilder. + */ + const getPreEncodedProposalMessage = async () => { + const seedBuilder = factory.from(testData.TEST_SUBMIT_PROPOSAL.randomMsgSubmitProposalEncoded); + const seedTx = await seedBuilder.build(); + // sendMessages[0].value is a Uint8Array after deserialization — the pre-encoded path + return seedTx.cosmosLikeTransaction.sendMessages[0]; + }; + + /** + * Builds a ContractCallBuilder using the pre-encoded proposal MessageData and the + * provided execType string, using TEST_CONTRACT_CALL metadata for chain parameters. + */ + const buildProposalTxWithExecType = async (execTypeValue: string) => { + const preEncodedMessage = await getPreEncodedProposalMessage(); + + const txBuilder = factory.getContractCallBuilder(); + txBuilder.sequence(testData.TEST_CONTRACT_CALL.sequence); + txBuilder.accountNumber(testData.TEST_CONTRACT_CALL.accountNumber); + txBuilder.chainId(testData.TEST_CONTRACT_CALL.chainId); + txBuilder.gasBudget({ + amount: [{ denom: 'nhash', amount: testData.TEST_CONTRACT_CALL.fee }], + gasLimit: testData.TEST_CONTRACT_CALL.gasLimit, + }); + txBuilder.publicKey(toHex(fromBase64(testData.TEST_CONTRACT_CALL.pubKey))); + // Passing the pre-encoded MessageData (value is Uint8Array) bypasses isGroupProposal + // detection and goes through the "pre-encoded round-trip" branch in messages() + txBuilder.messages([preEncodedMessage]); + txBuilder.execType(execTypeValue); + return txBuilder.build(); + }; + + it('should store execType on cosmosLikeTransaction when execType is set to EXEC_UNSPECIFIED', async function () { + // Verifies that calling execType('EXEC_UNSPECIFIED') propagates the value through + // createTransaction() into cosmosLikeTransaction.execType, which utils then uses + // to set decoded.exec = 0 when serializing the group proposal message. + const tx = await buildProposalTxWithExecType('EXEC_UNSPECIFIED'); + tx.cosmosLikeTransaction.execType.should.equal('EXEC_UNSPECIFIED'); + }); + + it('should store execType on cosmosLikeTransaction when execType is set to EXEC_TRY', async function () { + // Verifies that EXEC_TRY is stored correctly — this is the value that causes + // getSendMessagesForEncodingTx to set decoded.exec = 1 on the group proposal message. + const tx = await buildProposalTxWithExecType('EXEC_TRY'); + tx.cosmosLikeTransaction.execType.should.equal('EXEC_TRY'); + }); + + it('should leave execType undefined when execType() is never called', async function () { + // Verifies that omitting the execType() call leaves cosmosLikeTransaction.execType + // as undefined, which means getSendMessagesForEncodingTx will not override the exec + // field and the original encoded value in the message bytes is preserved. + const preEncodedMessage = await getPreEncodedProposalMessage(); + + const txBuilder = factory.getContractCallBuilder(); + txBuilder.sequence(testData.TEST_CONTRACT_CALL.sequence); + txBuilder.accountNumber(testData.TEST_CONTRACT_CALL.accountNumber); + txBuilder.chainId(testData.TEST_CONTRACT_CALL.chainId); + txBuilder.gasBudget({ + amount: [{ denom: 'nhash', amount: testData.TEST_CONTRACT_CALL.fee }], + gasLimit: testData.TEST_CONTRACT_CALL.gasLimit, + }); + txBuilder.publicKey(toHex(fromBase64(testData.TEST_CONTRACT_CALL.pubKey))); + txBuilder.messages([preEncodedMessage]); + // Intentionally not calling txBuilder.execType() to confirm the field remains undefined + const tx = await txBuilder.build(); + should.equal(tx.cosmosLikeTransaction.execType, undefined); + }); + }); });