Skip to content

Commit 44f6e39

Browse files
authored
Merge pull request #8083 from BitGo/feature/fee-granter-and-group-proposals
feat(abstract-cosmos): add contract call decoding
2 parents 6e2eb2c + 199876b commit 44f6e39

4 files changed

Lines changed: 154 additions & 32 deletions

File tree

modules/abstract-cosmos/src/lib/ContractCallBuilder.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { TransactionType } from '@bitgo/sdk-core';
22
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3-
import { fromBase64 } from '@cosmjs/encoding';
43

54
import * as constants from './constants';
65
import { CosmosTransactionMessage, ExecuteContractMessage, MessageData } from './iface';
@@ -20,32 +19,24 @@ export class ContractCallBuilder<CustomMessage = never> extends CosmosTransactio
2019
}
2120

2221
/** @inheritdoc */
23-
messages(messages: (CosmosTransactionMessage<CustomMessage> | MessageData<CustomMessage>)[]): this {
22+
messages(messages: (CosmosTransactionMessage<CustomMessage> | Partial<MessageData<CustomMessage>>)[]): this {
2423
this._messages = messages.map((message) => {
25-
const msg = message as MessageData<CustomMessage>;
26-
const { typeUrl, value } = msg;
27-
28-
// Handle pre-encoded messages (base64 string input)
29-
if (typeUrl && typeof value === 'string') {
30-
try {
31-
return { typeUrl, value: fromBase64(value) } as MessageData<CustomMessage>;
32-
} catch (err: unknown) {
33-
throw new Error(`Invalid base64 string in message value: ${String(err)}`);
34-
}
24+
const executeContractMessage = message as ExecuteContractMessage;
25+
26+
if (!executeContractMessage.msg) {
27+
// Pre-encoded message from deserialization round-trip
28+
return message as MessageData<CustomMessage>;
3529
}
3630

37-
// Handle already-encoded messages (Uint8Array from deserialization)
38-
if (typeUrl && value instanceof Uint8Array) {
39-
return { typeUrl, value } as MessageData<CustomMessage>;
31+
if (CosmosUtils.isGroupProposal(executeContractMessage)) {
32+
return {
33+
typeUrl: constants.groupProposalMsgTypeUrl,
34+
value: executeContractMessage.msg,
35+
} as MessageData<CustomMessage>;
4036
}
4137

42-
// Handle typed ExecuteContractMessage
43-
const executeContractMessage = message as ExecuteContractMessage;
4438
this._utils.validateExecuteContractMessage(executeContractMessage, this.transactionType);
45-
return {
46-
typeUrl: constants.executeContractMsgTypeUrl,
47-
value: executeContractMessage,
48-
};
39+
return { typeUrl: constants.executeContractMsgTypeUrl, value: executeContractMessage };
4940
});
5041
return this;
5142
}

modules/abstract-cosmos/src/lib/utils.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,70 @@ export class CosmosUtils<CustomMessage = never> implements BaseUtils {
998998
),
999999
];
10001000
}
1001+
1002+
/**
1003+
* Checks if an ExecuteContractMessage's msg field contains a group proposal.
1004+
* @param {ExecuteContractMessage} message - The execute contract message to check
1005+
* @returns {boolean} true if the msg decodes to a group proposal
1006+
*/
1007+
static isGroupProposal(message: ExecuteContractMessage): boolean {
1008+
if (!message.msg || message.msg.length === 0) {
1009+
return false;
1010+
}
1011+
const result = CosmosUtils.decodeMsg(message.msg);
1012+
return result.typeUrl === constants.groupProposalMsgTypeUrl;
1013+
}
1014+
1015+
/**
1016+
* Decodes a protobuf message and determines its type.
1017+
*
1018+
* @param data - Message data as base64 string or Uint8Array
1019+
* @returns Decoded message result with typeUrl if successfully identified
1020+
*/
1021+
static decodeMsg(data: string | Uint8Array): { typeUrl?: string; error?: string } {
1022+
try {
1023+
const messageBytes = typeof data === 'string' ? Buffer.from(data, 'base64') : data;
1024+
1025+
try {
1026+
const proposal = MsgSubmitProposal.decode(messageBytes);
1027+
if (
1028+
proposal.groupPolicyAddress &&
1029+
typeof proposal.groupPolicyAddress === 'string' &&
1030+
proposal.groupPolicyAddress.length > 0 &&
1031+
Array.isArray(proposal.proposers) &&
1032+
proposal.proposers.length > 0
1033+
) {
1034+
return { typeUrl: constants.groupProposalMsgTypeUrl };
1035+
}
1036+
} catch {
1037+
// Not a group proposal
1038+
}
1039+
1040+
try {
1041+
const executeMsg = MsgExecuteContract.decode(messageBytes);
1042+
if (
1043+
executeMsg.sender &&
1044+
typeof executeMsg.sender === 'string' &&
1045+
executeMsg.sender.length > 0 &&
1046+
executeMsg.contract &&
1047+
typeof executeMsg.contract === 'string' &&
1048+
executeMsg.contract.length > 0 &&
1049+
executeMsg.msg instanceof Uint8Array &&
1050+
executeMsg.msg.length > 0
1051+
) {
1052+
return { typeUrl: constants.executeContractMsgTypeUrl };
1053+
}
1054+
} catch {
1055+
// Not an execute contract message
1056+
}
1057+
1058+
return { error: 'Unable to decode message as any known type' };
1059+
} catch (error) {
1060+
return {
1061+
error: error instanceof Error ? error.message : 'Failed to decode message',
1062+
};
1063+
}
1064+
}
10011065
}
10021066

10031067
const utils = new CosmosUtils();

modules/sdk-coin-hash/test/unit/transactionBuilder/contractCallBuilder.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
44
import { fromBase64, toHex } from '@cosmjs/encoding';
55
import should from 'should';
66
import { Hash, Thash } from '../../../src';
7-
import { TEST_CONTRACT_CALL } from '../../resources/hash';
7+
import { TEST_CONTRACT_CALL, testnetAddress } from '../../resources/hash';
88

99
describe('Hash ContractCall Builder', () => {
1010
let bitgo: TestBitGoAPI;
@@ -24,17 +24,12 @@ describe('Hash ContractCall Builder', () => {
2424
txBuilder.feeGranter(TEST_CONTRACT_CALL.feeGranter);
2525
txBuilder.publicKey(toHex(fromBase64(TEST_CONTRACT_CALL.pubKey)));
2626

27-
// Wrap the inner message in a group proposal
28-
// const wrappedMessage = wrapInGroupProposal(
29-
// TEST_CONTRACT_CALL.preEncodedMessageValue,
30-
// TEST_CONTRACT_CALL.proposer,
31-
// testnetAddress.groupPolicyAddress
32-
// );
33-
3427
txBuilder.messages([
3528
{
36-
typeUrl: '/cosmos.group.v1.MsgSubmitProposal',
37-
value: TEST_CONTRACT_CALL.encodedProposal,
29+
sender: TEST_CONTRACT_CALL.proposer,
30+
contract: testnetAddress.groupPolicyAddress,
31+
msg: fromBase64(TEST_CONTRACT_CALL.encodedProposal),
32+
funds: [],
3833
},
3934
]);
4035
return txBuilder;

modules/sdk-coin-hash/test/unit/utils.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import should from 'should';
2+
import { CosmosUtils } from '@bitgo/abstract-cosmos';
23

34
import utils from '../../src/lib/utils';
45
import * as testData from '../resources/hash';
5-
import { blockHash, txIds } from '../resources/hash';
6+
import { blockHash, txIds, TEST_CONTRACT_CALL } from '../resources/hash';
67

78
describe('utils', () => {
89
it('should validate block hash correctly', () => {
@@ -44,4 +45,75 @@ describe('utils', () => {
4445
'transactionBuilder: validateAmount: Invalid denom: ' + testData.coinAmounts.amount5.denom
4546
);
4647
});
48+
49+
describe('decodeMsg', () => {
50+
it('should detect valid base64-encoded group proposal', () => {
51+
const result = CosmosUtils.decodeMsg(TEST_CONTRACT_CALL.encodedProposal);
52+
53+
should.exist(result.typeUrl);
54+
if (result.typeUrl) {
55+
result.typeUrl.should.equal('/cosmos.group.v1.MsgSubmitProposal');
56+
}
57+
should.not.exist(result.error);
58+
});
59+
60+
it('should reject invalid base64 string', () => {
61+
const result = CosmosUtils.decodeMsg('not-valid-base64!!!');
62+
63+
should.not.exist(result.typeUrl);
64+
should.exist(result.error);
65+
});
66+
67+
it('should reject valid base64 but invalid protobuf', () => {
68+
const result = CosmosUtils.decodeMsg(Buffer.from('random data').toString('base64'));
69+
70+
should.not.exist(result.typeUrl);
71+
should.exist(result.error);
72+
});
73+
74+
it('should reject hex-encoded contract call data', () => {
75+
const result = CosmosUtils.decodeMsg('7b22696e6372656d656e74223a7b7d7d');
76+
77+
should.not.exist(result.typeUrl);
78+
});
79+
80+
it('should accept Uint8Array input', () => {
81+
const bytes = Buffer.from(TEST_CONTRACT_CALL.encodedProposal, 'base64');
82+
const result = CosmosUtils.decodeMsg(bytes);
83+
84+
should.exist(result.typeUrl);
85+
if (result.typeUrl) {
86+
result.typeUrl.should.equal('/cosmos.group.v1.MsgSubmitProposal');
87+
}
88+
});
89+
});
90+
91+
describe('isGroupProposal', () => {
92+
it('should return true when msg contains a group proposal', () => {
93+
const message = {
94+
sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u',
95+
contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld',
96+
msg: Buffer.from(TEST_CONTRACT_CALL.encodedProposal, 'base64'),
97+
};
98+
should.equal(CosmosUtils.isGroupProposal(message), true);
99+
});
100+
101+
it('should return false when msg contains regular contract call data', () => {
102+
const message = {
103+
sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u',
104+
contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld',
105+
msg: Buffer.from(JSON.stringify({ increment: {} })),
106+
};
107+
should.equal(CosmosUtils.isGroupProposal(message), false);
108+
});
109+
110+
it('should return false when msg is empty', () => {
111+
const message = {
112+
sender: 'tp1tazefwk2e372fy2jq08w6lztg9yrrvс490r2gp4vt8d0fchlrfqqyahg0u',
113+
contract: 'tp12nyn83ynewtmpkw32wq6dg83wx8nqpat65gcld',
114+
msg: new Uint8Array(0),
115+
};
116+
should.equal(CosmosUtils.isGroupProposal(message), false);
117+
});
118+
});
47119
});

0 commit comments

Comments
 (0)