From 67cceb22881ee9a111ecbb815851239bbd6d71dc Mon Sep 17 00:00:00 2001 From: Sreeraj S Date: Tue, 10 Feb 2026 14:23:21 +0530 Subject: [PATCH 1/2] feat(sdk-coin-hash): suport 0 value for contract call Ticket: WIN-8842 --- modules/sdk-coin-hash/src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/sdk-coin-hash/src/lib/utils.ts b/modules/sdk-coin-hash/src/lib/utils.ts index 1327696e9a..a92f6b30cf 100644 --- a/modules/sdk-coin-hash/src/lib/utils.ts +++ b/modules/sdk-coin-hash/src/lib/utils.ts @@ -38,7 +38,7 @@ export class HashUtils extends CosmosUtils { /** @inheritdoc */ validateAmount(amount: Coin): void { const amountBig = BigNumber(amount.amount); - if (amountBig.isLessThanOrEqualTo(0)) { + if (amountBig.isLessThan(0)) { throw new InvalidTransactionError('transactionBuilder: validateAmount: Invalid amount: ' + amount.amount); } if (!constants.validDenoms.find((denom) => denom === amount.denom)) { From be7d2489a9aefd02d196fa8ecc574704f8375134 Mon Sep 17 00:00:00 2001 From: Sreeraj S Date: Wed, 11 Feb 2026 01:14:00 +0530 Subject: [PATCH 2/2] feat(abstract-cosmos,sdk-coin-hash): add MsgVote support Ticket: WIN-8842 --- .../resources/ProposalCompiled.d.ts | 52 +++ .../resources/ProposalCompiled.js | 394 ++++++++++++++++++ .../src/lib/ContractCallBuilder.ts | 7 + modules/abstract-cosmos/src/lib/constants.ts | 1 + modules/abstract-cosmos/src/lib/utils.ts | 39 +- modules/sdk-coin-hash/test/resources/hash.ts | 18 + .../transactionBuilder/contractCallBuilder.ts | 70 +++- modules/sdk-coin-hash/test/unit/utils.ts | 53 ++- 8 files changed, 628 insertions(+), 6 deletions(-) diff --git a/modules/abstract-cosmos/resources/ProposalCompiled.d.ts b/modules/abstract-cosmos/resources/ProposalCompiled.d.ts index b718ff1703..ffd98bea2d 100644 --- a/modules/abstract-cosmos/resources/ProposalCompiled.d.ts +++ b/modules/abstract-cosmos/resources/ProposalCompiled.d.ts @@ -147,6 +147,58 @@ export namespace cosmos { EXEC_UNSPECIFIED = 0, EXEC_TRY = 1, } + + /** VoteOption enum. */ + enum VoteOption { + VOTE_OPTION_UNSPECIFIED = 0, + VOTE_OPTION_YES = 1, + VOTE_OPTION_ABSTAIN = 2, + VOTE_OPTION_NO = 3, + VOTE_OPTION_NO_WITH_VETO = 4, + } + + /** Properties of a MsgVote. */ + interface IMsgVote { + /** MsgVote proposalId */ + proposalId?: number | Long | null; + + /** MsgVote voter */ + voter?: string | null; + + /** MsgVote option */ + option?: cosmos.group.v1.VoteOption | null; + + /** MsgVote metadata */ + metadata?: string | null; + + /** MsgVote exec */ + exec?: cosmos.group.v1.Exec | null; + } + + /** Represents a MsgVote. */ + class MsgVote implements IMsgVote { + constructor(properties?: cosmos.group.v1.IMsgVote); + + public proposalId: number | Long; + public voter: string; + public option: cosmos.group.v1.VoteOption; + public metadata: string; + public exec: cosmos.group.v1.Exec; + + public static create(properties?: cosmos.group.v1.IMsgVote): cosmos.group.v1.MsgVote; + public static encode(message: cosmos.group.v1.IMsgVote, writer?: $protobuf.Writer): $protobuf.Writer; + public static encodeDelimited(message: cosmos.group.v1.IMsgVote, writer?: $protobuf.Writer): $protobuf.Writer; + public static decode(reader: $protobuf.Reader | Uint8Array, length?: number): cosmos.group.v1.MsgVote; + public static decodeDelimited(reader: $protobuf.Reader | Uint8Array): cosmos.group.v1.MsgVote; + public static verify(message: { [k: string]: any }): string | null; + public static fromObject(object: { [k: string]: any }): cosmos.group.v1.MsgVote; + public static toObject( + message: cosmos.group.v1.MsgVote, + options?: $protobuf.IConversionOptions + ): { [k: string]: any }; + public toJSON(): { [k: string]: any }; + public static getTypeUrl(typeUrlPrefix?: string): string; + } } } } diff --git a/modules/abstract-cosmos/resources/ProposalCompiled.js b/modules/abstract-cosmos/resources/ProposalCompiled.js index f8302f8c1d..08865459b5 100644 --- a/modules/abstract-cosmos/resources/ProposalCompiled.js +++ b/modules/abstract-cosmos/resources/ProposalCompiled.js @@ -433,6 +433,400 @@ $root.cosmos = (function () { return values; })(); + /** + * VoteOption enum. + * @name cosmos.group.v1.VoteOption + * @enum {number} + * @property {number} VOTE_OPTION_UNSPECIFIED=0 VOTE_OPTION_UNSPECIFIED value + * @property {number} VOTE_OPTION_YES=1 VOTE_OPTION_YES value + * @property {number} VOTE_OPTION_ABSTAIN=2 VOTE_OPTION_ABSTAIN value + * @property {number} VOTE_OPTION_NO=3 VOTE_OPTION_NO value + * @property {number} VOTE_OPTION_NO_WITH_VETO=4 VOTE_OPTION_NO_WITH_VETO value + */ + v1.VoteOption = (function () { + var valuesById = {}, + values = Object.create(valuesById); + values[(valuesById[0] = 'VOTE_OPTION_UNSPECIFIED')] = 0; + values[(valuesById[1] = 'VOTE_OPTION_YES')] = 1; + values[(valuesById[2] = 'VOTE_OPTION_ABSTAIN')] = 2; + values[(valuesById[3] = 'VOTE_OPTION_NO')] = 3; + values[(valuesById[4] = 'VOTE_OPTION_NO_WITH_VETO')] = 4; + return values; + })(); + + v1.MsgVote = (function () { + /** + * Properties of a MsgVote. + * @memberof cosmos.group.v1 + * @interface IMsgVote + * @property {number|Long|null} [proposalId] MsgVote proposalId + * @property {string|null} [voter] MsgVote voter + * @property {cosmos.group.v1.VoteOption|null} [option] MsgVote option + * @property {string|null} [metadata] MsgVote metadata + * @property {cosmos.group.v1.Exec|null} [exec] MsgVote exec + */ + + /** + * Constructs a new MsgVote. + * @memberof cosmos.group.v1 + * @classdesc Represents a MsgVote. + * @implements IMsgVote + * @constructor + * @param {cosmos.group.v1.IMsgVote=} [properties] Properties to set + */ + function MsgVote(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) this[keys[i]] = properties[keys[i]]; + } + + /** + * MsgVote proposalId. + * @member {number|Long} proposalId + * @memberof cosmos.group.v1.MsgVote + * @instance + */ + MsgVote.prototype.proposalId = $util.Long ? $util.Long.fromBits(0, 0, true) : 0; + + /** + * MsgVote voter. + * @member {string} voter + * @memberof cosmos.group.v1.MsgVote + * @instance + */ + MsgVote.prototype.voter = ''; + + /** + * MsgVote option. + * @member {cosmos.group.v1.VoteOption} option + * @memberof cosmos.group.v1.MsgVote + * @instance + */ + MsgVote.prototype.option = 0; + + /** + * MsgVote metadata. + * @member {string} metadata + * @memberof cosmos.group.v1.MsgVote + * @instance + */ + MsgVote.prototype.metadata = ''; + + /** + * MsgVote exec. + * @member {cosmos.group.v1.Exec} exec + * @memberof cosmos.group.v1.MsgVote + * @instance + */ + MsgVote.prototype.exec = 0; + + /** + * Creates a new MsgVote instance using the specified properties. + * @function create + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {cosmos.group.v1.IMsgVote=} [properties] Properties to set + * @returns {cosmos.group.v1.MsgVote} MsgVote instance + */ + MsgVote.create = function create(properties) { + return new MsgVote(properties); + }; + + /** + * Encodes the specified MsgVote message. Does not implicitly {@link cosmos.group.v1.MsgVote.verify|verify} messages. + * @function encode + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {cosmos.group.v1.IMsgVote} message MsgVote message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + MsgVote.encode = function encode(message, writer) { + if (!writer) writer = $Writer.create(); + if (message.proposalId != null && Object.hasOwnProperty.call(message, 'proposalId')) + writer.uint32(/* id 1, wireType 0 =*/ 8).uint64(message.proposalId); + if (message.voter != null && Object.hasOwnProperty.call(message, 'voter')) + writer.uint32(/* id 2, wireType 2 =*/ 18).string(message.voter); + if (message.option != null && Object.hasOwnProperty.call(message, 'option')) + writer.uint32(/* id 3, wireType 0 =*/ 24).int32(message.option); + if (message.metadata != null && Object.hasOwnProperty.call(message, 'metadata')) + writer.uint32(/* id 4, wireType 2 =*/ 34).string(message.metadata); + if (message.exec != null && Object.hasOwnProperty.call(message, 'exec')) + writer.uint32(/* id 5, wireType 0 =*/ 40).int32(message.exec); + return writer; + }; + + /** + * Encodes the specified MsgVote message, length delimited. Does not implicitly {@link cosmos.group.v1.MsgVote.verify|verify} messages. + * @function encodeDelimited + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {cosmos.group.v1.IMsgVote} message MsgVote message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + MsgVote.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a MsgVote message from the specified reader or buffer. + * @function decode + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {cosmos.group.v1.MsgVote} MsgVote + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + MsgVote.decode = function decode(reader, length, error) { + if (!(reader instanceof $Reader)) reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, + message = new $root.cosmos.group.v1.MsgVote(); + while (reader.pos < end) { + var tag = reader.uint32(); + if (tag === error) break; + switch (tag >>> 3) { + case 1: { + message.proposalId = reader.uint64(); + break; + } + case 2: { + message.voter = reader.string(); + break; + } + case 3: { + message.option = reader.int32(); + break; + } + case 4: { + message.metadata = reader.string(); + break; + } + case 5: { + message.exec = reader.int32(); + break; + } + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a MsgVote message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {cosmos.group.v1.MsgVote} MsgVote + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + MsgVote.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a MsgVote message. + * @function verify + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + MsgVote.verify = function verify(message) { + if (typeof message !== 'object' || message === null) return 'object expected'; + if (message.proposalId != null && message.hasOwnProperty('proposalId')) + if ( + !$util.isInteger(message.proposalId) && + !( + message.proposalId && + $util.isInteger(message.proposalId.low) && + $util.isInteger(message.proposalId.high) + ) + ) + return 'proposalId: integer|Long expected'; + if (message.voter != null && message.hasOwnProperty('voter')) + if (!$util.isString(message.voter)) return 'voter: string expected'; + if (message.option != null && message.hasOwnProperty('option')) + switch (message.option) { + default: + return 'option: enum value expected'; + case 0: + case 1: + case 2: + case 3: + case 4: + break; + } + if (message.metadata != null && message.hasOwnProperty('metadata')) + if (!$util.isString(message.metadata)) return 'metadata: string expected'; + if (message.exec != null && message.hasOwnProperty('exec')) + switch (message.exec) { + default: + return 'exec: enum value expected'; + case 0: + case 1: + break; + } + return null; + }; + + /** + * Creates a MsgVote message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {Object.} object Plain object + * @returns {cosmos.group.v1.MsgVote} MsgVote + */ + MsgVote.fromObject = function fromObject(object) { + if (object instanceof $root.cosmos.group.v1.MsgVote) return object; + var message = new $root.cosmos.group.v1.MsgVote(); + if (object.proposalId != null) + if ($util.Long) (message.proposalId = $util.Long.fromValue(object.proposalId)).unsigned = true; + else if (typeof object.proposalId === 'string') message.proposalId = parseInt(object.proposalId, 10); + else if (typeof object.proposalId === 'number') message.proposalId = object.proposalId; + else if (typeof object.proposalId === 'object') + message.proposalId = new $util.LongBits( + object.proposalId.low >>> 0, + object.proposalId.high >>> 0 + ).toNumber(true); + if (object.voter != null) message.voter = String(object.voter); + switch (object.option) { + default: + if (typeof object.option === 'number') { + message.option = object.option; + break; + } + break; + case 'VOTE_OPTION_UNSPECIFIED': + case 0: + message.option = 0; + break; + case 'VOTE_OPTION_YES': + case 1: + message.option = 1; + break; + case 'VOTE_OPTION_ABSTAIN': + case 2: + message.option = 2; + break; + case 'VOTE_OPTION_NO': + case 3: + message.option = 3; + break; + case 'VOTE_OPTION_NO_WITH_VETO': + case 4: + message.option = 4; + break; + } + if (object.metadata != null) message.metadata = String(object.metadata); + switch (object.exec) { + default: + if (typeof object.exec === 'number') { + message.exec = object.exec; + break; + } + break; + case 'EXEC_UNSPECIFIED': + case 0: + message.exec = 0; + break; + case 'EXEC_TRY': + case 1: + message.exec = 1; + break; + } + return message; + }; + + /** + * Creates a plain object from a MsgVote message. Also converts values to other types if specified. + * @function toObject + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {cosmos.group.v1.MsgVote} message MsgVote + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + MsgVote.toObject = function toObject(message, options) { + if (!options) options = {}; + var object = {}; + if (options.defaults) { + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.proposalId = + options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else object.proposalId = options.longs === String ? '0' : 0; + object.voter = ''; + object.option = options.enums === String ? 'VOTE_OPTION_UNSPECIFIED' : 0; + object.metadata = ''; + object.exec = options.enums === String ? 'EXEC_UNSPECIFIED' : 0; + } + if (message.proposalId != null && message.hasOwnProperty('proposalId')) + if (typeof message.proposalId === 'number') + object.proposalId = options.longs === String ? String(message.proposalId) : message.proposalId; + else + object.proposalId = + options.longs === String + ? $util.Long.prototype.toString.call(message.proposalId) + : options.longs === Number + ? new $util.LongBits(message.proposalId.low >>> 0, message.proposalId.high >>> 0).toNumber(true) + : message.proposalId; + if (message.voter != null && message.hasOwnProperty('voter')) object.voter = message.voter; + if (message.option != null && message.hasOwnProperty('option')) + object.option = + options.enums === String + ? $root.cosmos.group.v1.VoteOption[message.option] === undefined + ? message.option + : $root.cosmos.group.v1.VoteOption[message.option] + : message.option; + if (message.metadata != null && message.hasOwnProperty('metadata')) object.metadata = message.metadata; + if (message.exec != null && message.hasOwnProperty('exec')) + object.exec = + options.enums === String + ? $root.cosmos.group.v1.Exec[message.exec] === undefined + ? message.exec + : $root.cosmos.group.v1.Exec[message.exec] + : message.exec; + return object; + }; + + /** + * Converts this MsgVote to JSON. + * @function toJSON + * @memberof cosmos.group.v1.MsgVote + * @instance + * @returns {Object.} JSON object + */ + MsgVote.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + /** + * Gets the default type url for MsgVote + * @function getTypeUrl + * @memberof cosmos.group.v1.MsgVote + * @static + * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com") + * @returns {string} The default type url + */ + MsgVote.getTypeUrl = function getTypeUrl(typeUrlPrefix) { + if (typeUrlPrefix === undefined) { + typeUrlPrefix = 'type.googleapis.com'; + } + return typeUrlPrefix + '/cosmos.group.v1.MsgVote'; + }; + + return MsgVote; + })(); + return v1; })(); diff --git a/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts b/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts index 92d811360a..5524e6a386 100644 --- a/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts +++ b/modules/abstract-cosmos/src/lib/ContractCallBuilder.ts @@ -35,6 +35,13 @@ export class ContractCallBuilder extends CosmosTransactio } as MessageData; } + if (CosmosUtils.isGroupVote(executeContractMessage)) { + return { + typeUrl: constants.groupVoteMsgTypeUrl, + value: executeContractMessage.msg, + } as MessageData; + } + this._utils.validateExecuteContractMessage(executeContractMessage, this.transactionType); return { typeUrl: constants.executeContractMsgTypeUrl, value: executeContractMessage }; }); diff --git a/modules/abstract-cosmos/src/lib/constants.ts b/modules/abstract-cosmos/src/lib/constants.ts index 35e5461511..caf466a930 100644 --- a/modules/abstract-cosmos/src/lib/constants.ts +++ b/modules/abstract-cosmos/src/lib/constants.ts @@ -6,6 +6,7 @@ export const redelegateTypeUrl = '/cosmos.staking.v1beta1.MsgBeginRedelegate'; export const withdrawDelegatorRewardMsgTypeUrl = '/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward'; export const executeContractMsgTypeUrl = '/cosmwasm.wasm.v1.MsgExecuteContract'; export const groupProposalMsgTypeUrl = '/cosmos.group.v1.MsgSubmitProposal'; +export const groupVoteMsgTypeUrl = '/cosmos.group.v1.MsgVote'; export const UNAVAILABLE_TEXT = 'UNAVAILABLE'; export const ROOT_PATH = 'm/0'; export const sendMsgType = '/types.MsgSend'; // thorchain uses this custom message type diff --git a/modules/abstract-cosmos/src/lib/utils.ts b/modules/abstract-cosmos/src/lib/utils.ts index cdbedda083..16293a2356 100644 --- a/modules/abstract-cosmos/src/lib/utils.ts +++ b/modules/abstract-cosmos/src/lib/utils.ts @@ -41,7 +41,7 @@ import { initializeProtobufModules } from './protobuf-init'; const { MsgCompiled, ProposalCompiled } = initializeProtobufModules(); const { MsgSend } = MsgCompiled.types; -const { MsgSubmitProposal } = ProposalCompiled.cosmos.group.v1; +const { MsgSubmitProposal, MsgVote } = ProposalCompiled.cosmos.group.v1; export class CosmosUtils implements BaseUtils { protected registry; @@ -51,6 +51,7 @@ export class CosmosUtils implements BaseUtils { this.registry.register(constants.executeContractMsgTypeUrl, MsgExecuteContract); this.registry.register('/types.MsgSend', MsgSend); this.registry.register(constants.groupProposalMsgTypeUrl, MsgSubmitProposal); + this.registry.register(constants.groupVoteMsgTypeUrl, MsgVote); } /** @inheritdoc */ @@ -330,7 +331,7 @@ export class CosmosUtils implements BaseUtils { */ getExecuteContractMessageDataFromDecodedTx(decodedTx: DecodedTxRaw): MessageData[] { return decodedTx.body.messages.map((message) => { - if (message.typeUrl !== constants.groupProposalMsgTypeUrl) { + if (message.typeUrl !== constants.groupProposalMsgTypeUrl && message.typeUrl !== constants.groupVoteMsgTypeUrl) { try { const value = this.registry.decode(message); return { @@ -384,6 +385,8 @@ export class CosmosUtils implements BaseUtils { return TransactionType.ContractCall; case constants.groupProposalMsgTypeUrl: return TransactionType.ContractCall; + case constants.groupVoteMsgTypeUrl: + return TransactionType.ContractCall; case constants.redelegateTypeUrl: return TransactionType.StakingRedelegate; default: @@ -409,8 +412,8 @@ export class CosmosUtils implements BaseUtils { getSendMessagesForEncodingTx(cosmosLikeTransaction: CosmosLikeTransaction): Any[] { return cosmosLikeTransaction.sendMessages.map((msg) => { if (msg.value instanceof Uint8Array) { - if (msg.typeUrl === constants.groupProposalMsgTypeUrl) { - // For group proposal messages, the pre-encoded bytes contain the full MsgSubmitProposal + if (msg.typeUrl === constants.groupProposalMsgTypeUrl || msg.typeUrl === constants.groupVoteMsgTypeUrl) { + // 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 }); return { @@ -1012,6 +1015,19 @@ export class CosmosUtils implements BaseUtils { return result.typeUrl === constants.groupProposalMsgTypeUrl; } + /** + * Checks if an ExecuteContractMessage's msg field contains a group vote. + * @param {ExecuteContractMessage} message - The execute contract message to check + * @returns {boolean} true if the msg decodes to a group vote + */ + static isGroupVote(message: ExecuteContractMessage): boolean { + if (!message.msg || message.msg.length === 0) { + return false; + } + const result = CosmosUtils.decodeMsg(message.msg); + return result.typeUrl === constants.groupVoteMsgTypeUrl; + } + /** * Decodes a protobuf message and determines its type. * @@ -1037,6 +1053,21 @@ export class CosmosUtils implements BaseUtils { // Not a group proposal } + try { + const vote = MsgVote.decode(messageBytes); + if ( + vote.voter && + typeof vote.voter === 'string' && + vote.voter.length > 0 && + vote.proposalId !== undefined && + vote.proposalId !== null + ) { + return { typeUrl: constants.groupVoteMsgTypeUrl }; + } + } catch { + // Not a group vote + } + try { const executeMsg = MsgExecuteContract.decode(messageBytes); if ( diff --git a/modules/sdk-coin-hash/test/resources/hash.ts b/modules/sdk-coin-hash/test/resources/hash.ts index c6fecd2d9c..5c295513c0 100644 --- a/modules/sdk-coin-hash/test/resources/hash.ts +++ b/modules/sdk-coin-hash/test/resources/hash.ts @@ -21,6 +21,24 @@ export const TEST_CONTRACT_CALL = { '0ae9020ae6020a222f636f736d6f732e67726f75702e76312e4d73675375626d697450726f706f73616c12bf020a3d74703174617a6566776b32653337326679326a71303877366c7a7467397972727663343930723267703476743864306663686c726671717961686730751229747031326e796e3833796e6577746d706b773332777136646738337778386e71706174363567636c641a0f65786368616e67652d636f6d6d697422bf010a2d2f70726f76656e616e63652e65786368616e67652e76312e4d7367436f6d6d697446756e647352657175657374128d010a3d74703174617a6566776b32653337326679326a71303877366c7a7467397972727663343930723267703476743864306663686c7266717179616867307510011a140a0975796c64732e6663631207313030303030302a3465786368616e67652d636f6d6d69743a32316561363334302d393961662d346338632d386661302d37356665306431626264343428011299010a500a460a1f2f636f736d6f732e63727970746f2e736563703235366b312e5075624b657912230a21039f03af548098ff794456d05f2adcc389cfc04abc6e16d92669a6255e33145b2112040a020801181012450a140a056e68617368120b34303030303030303030301090a10f22297470313276646e72376464636b78306d38753632717573727a713572363663656a35726434397a77661a0d70696f2d746573746e65742d3120f2cc0e', }; +export const TEST_GROUP_VOTE = { + // Real encoded MsgVote payload from explorer tx 29CDCDFFB38AE89BA0311D040BB0D83541B4E5B2973C12DE5B00E6C0F9078B12 + encodedVote: 'COn8ChIpdHAxZGoybjV5NDdheXEydDg0cGF5OGN5eTY1emg2ZTV1NWowZGpuajcYASICe30oAQ==', + pubKey: 'A58Dr1SAmP95RFbQXyrcw4nPwEq8bhbZJmmmJV4zFFsh', + privateKey: 'sW1/vJr2qhN8MtrM8oK6xGKDTBo1VxMdLEP4yJzcEz4=', + feeGranter: 'tp12vdnr7ddckx0m8u62qusrzq5r66cej5rd49zwf', + voter: 'tp1dj2n5y47ayq2t84pay8cyy65zh6e5u5j0djnj7', + chainId: 'pio-testnet-1', + accountNumber: 239218, + sequence: 17, + fee: '40000000000', + gasLimit: 250000, + proposalId: '179817', + messageTypeUrl: '/cosmos.group.v1.MsgVote', + expectedSignBytesHex: + '0a550a530a182f636f736d6f732e67726f75702e76312e4d7367566f7465123708e9fc0a1229747031646a326e35793437617971327438347061793863797936357a6836653575356a30646a6e6a37180122027b7d28011299010a500a460a1f2f636f736d6f732e63727970746f2e736563703235366b312e5075624b657912230a21039f03af548098ff794456d05f2adcc389cfc04abc6e16d92669a6255e33145b2112040a020801181112450a140a056e68617368120b34303030303030303030301090a10f22297470313276646e72376464636b78306d38753632717573727a713572363663656a35726434397a77661a0d70696f2d746573746e65742d3120f2cc0e', +}; + export const TEST_ACCOUNT = { pubAddress: 'pb1496r8u4a48k6khknrhzd6c8cm3c64ewxhlyxpc', testnetPubAddress: 'tp1496r8u4a48k6khknrhzd6c8cm3c64ewxy5p2rj', diff --git a/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts b/modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts index d178a1e226..dc0d25813b 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, testnetAddress } from '../../resources/hash'; +import { TEST_CONTRACT_CALL, TEST_GROUP_VOTE, testnetAddress } from '../../resources/hash'; describe('Hash ContractCall Builder', () => { let bitgo: TestBitGoAPI; @@ -44,6 +44,30 @@ describe('Hash ContractCall Builder', () => { factory = basecoin.getBuilder(); }); + // Helper function to create a group vote transaction builder + const groupVoteBuilder = () => { + const txBuilder = factory.getContractCallBuilder(); + txBuilder.sequence(TEST_GROUP_VOTE.sequence); + txBuilder.accountNumber(TEST_GROUP_VOTE.accountNumber); + txBuilder.chainId(TEST_GROUP_VOTE.chainId); + txBuilder.gasBudget({ + amount: [{ denom: 'nhash', amount: TEST_GROUP_VOTE.fee }], + gasLimit: TEST_GROUP_VOTE.gasLimit, + }); + txBuilder.feeGranter(TEST_GROUP_VOTE.feeGranter); + txBuilder.publicKey(toHex(fromBase64(TEST_GROUP_VOTE.pubKey))); + + txBuilder.messages([ + { + sender: TEST_GROUP_VOTE.voter, + contract: testnetAddress.groupPolicyAddress, + msg: fromBase64(TEST_GROUP_VOTE.encodedVote), + funds: [], + }, + ]); + return txBuilder; + }; + describe('Contract Call Builder Tests', () => { it('should build transaction with expected signable payload', async function () { const txBuilder = contractCallBuilder(); @@ -91,4 +115,48 @@ describe('Hash ContractCall Builder', () => { should.equal(rebuiltSignedTx.signature[0], signedTx.signature[0]); }); }); + + describe('Group Vote Builder Tests', () => { + it('should build vote transaction with expected signable payload', async function () { + const txBuilder = groupVoteBuilder(); + const tx = await txBuilder.build(); + should.equal(toHex(tx.signablePayload), TEST_GROUP_VOTE.expectedSignBytesHex); + should.equal(tx.type, TransactionType.ContractCall); + }); + + it('should build, sign, and serialize group vote transactions', async function () { + const unsignedBuilder = groupVoteBuilder(); + const unsignedTx = await unsignedBuilder.build(); + should.equal(unsignedTx.type, TransactionType.ContractCall); + should.equal(unsignedTx.signature.length, 0); + should.exist(unsignedTx.toBroadcastFormat()); + + const signedBuilder = groupVoteBuilder(); + signedBuilder.sign({ key: toHex(fromBase64(TEST_GROUP_VOTE.privateKey)) }); + const signedTx = await signedBuilder.build(); + should.equal(signedTx.type, TransactionType.ContractCall); + should.equal(signedTx.signature.length, 1); + should.exist(signedTx.toBroadcastFormat()); + }); + + it('should handle round-trip serialization for group vote transactions', async function () { + const unsignedBuilder = groupVoteBuilder(); + const unsignedTx = await unsignedBuilder.build(); + const unsignedRaw = unsignedTx.toBroadcastFormat(); + const rebuiltUnsigned = factory.from(unsignedRaw); + const rebuiltUnsignedTx = await rebuiltUnsigned.build(); + should.equal(rebuiltUnsignedTx.toBroadcastFormat(), unsignedRaw); + should.equal(rebuiltUnsignedTx.type, TransactionType.ContractCall); + + const signedBuilder = groupVoteBuilder(); + signedBuilder.sign({ key: toHex(fromBase64(TEST_GROUP_VOTE.privateKey)) }); + const signedTx = await signedBuilder.build(); + const signedRaw = signedTx.toBroadcastFormat(); + const rebuiltSigned = factory.from(signedRaw); + const rebuiltSignedTx = await rebuiltSigned.build(); + should.equal(rebuiltSignedTx.toBroadcastFormat(), signedRaw); + should.equal(rebuiltSignedTx.signature.length, 1); + should.equal(rebuiltSignedTx.signature[0], signedTx.signature[0]); + }); + }); }); diff --git a/modules/sdk-coin-hash/test/unit/utils.ts b/modules/sdk-coin-hash/test/unit/utils.ts index dffca74a70..e8ee80ede3 100644 --- a/modules/sdk-coin-hash/test/unit/utils.ts +++ b/modules/sdk-coin-hash/test/unit/utils.ts @@ -3,7 +3,7 @@ import { CosmosUtils } from '@bitgo/abstract-cosmos'; import utils from '../../src/lib/utils'; import * as testData from '../resources/hash'; -import { blockHash, txIds, TEST_CONTRACT_CALL } from '../resources/hash'; +import { blockHash, txIds, TEST_CONTRACT_CALL, TEST_GROUP_VOTE } from '../resources/hash'; describe('utils', () => { it('should validate block hash correctly', () => { @@ -88,6 +88,57 @@ describe('utils', () => { }); }); + describe('decodeMsg - group vote', () => { + it('should detect valid base64-encoded group vote', () => { + const result = CosmosUtils.decodeMsg(TEST_GROUP_VOTE.encodedVote); + + should.exist(result.typeUrl); + if (result.typeUrl) { + result.typeUrl.should.equal('/cosmos.group.v1.MsgVote'); + } + should.not.exist(result.error); + }); + + it('should accept Uint8Array input for group vote', () => { + const bytes = Buffer.from(TEST_GROUP_VOTE.encodedVote, 'base64'); + const result = CosmosUtils.decodeMsg(bytes); + + should.exist(result.typeUrl); + if (result.typeUrl) { + result.typeUrl.should.equal('/cosmos.group.v1.MsgVote'); + } + }); + }); + + describe('isGroupVote', () => { + it('should return true when msg contains a group vote', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvc490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: Buffer.from(TEST_GROUP_VOTE.encodedVote, 'base64'), + }; + should.equal(CosmosUtils.isGroupVote(message), true); + }); + + it('should return false when msg contains a group proposal', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvc490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: Buffer.from(TEST_CONTRACT_CALL.encodedProposal, 'base64'), + }; + should.equal(CosmosUtils.isGroupVote(message), false); + }); + + it('should return false when msg is empty', () => { + const message = { + sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvc490r2gp4vt8d0fchlrfqqyahg0u', + contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld', + msg: new Uint8Array(0), + }; + should.equal(CosmosUtils.isGroupVote(message), false); + }); + }); + describe('isGroupProposal', () => { it('should return true when msg contains a group proposal', () => { const message = {