Skip to content
Merged
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
33 changes: 12 additions & 21 deletions modules/abstract-cosmos/src/lib/ContractCallBuilder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,32 +19,24 @@ export class ContractCallBuilder<CustomMessage = never> extends CosmosTransactio
}

/** @inheritdoc */
messages(messages: (CosmosTransactionMessage<CustomMessage> | MessageData<CustomMessage>)[]): this {
messages(messages: (CosmosTransactionMessage<CustomMessage> | Partial<MessageData<CustomMessage>>)[]): this {
this._messages = messages.map((message) => {
const msg = message as MessageData<CustomMessage>;
const { typeUrl, value } = msg;

// Handle pre-encoded messages (base64 string input)
if (typeUrl && typeof value === 'string') {
try {
return { typeUrl, value: fromBase64(value) } as MessageData<CustomMessage>;
} 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<CustomMessage>;
}

// Handle already-encoded messages (Uint8Array from deserialization)
if (typeUrl && value instanceof Uint8Array) {
return { typeUrl, value } as MessageData<CustomMessage>;
if (CosmosUtils.isGroupProposal(executeContractMessage)) {
return {
typeUrl: constants.groupProposalMsgTypeUrl,
value: executeContractMessage.msg,
} as MessageData<CustomMessage>;
}

// 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;
}
Expand Down
64 changes: 64 additions & 0 deletions modules/abstract-cosmos/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,70 @@ export class CosmosUtils<CustomMessage = never> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
74 changes: 73 additions & 1 deletion modules/sdk-coin-hash/test/unit/utils.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});