Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5980118
feat: remove fs
Poafs1 Jun 30, 2025
78da034
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Jul 1, 2025
564b5b8
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Jul 5, 2025
f2517dd
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Aug 5, 2025
00a0a04
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Aug 22, 2025
ebfa9d0
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Oct 8, 2025
6e974e0
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Oct 21, 2025
ee39e39
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Oct 29, 2025
77aa233
feat: add authz and feegrant support
tansawit Nov 3, 2025
d5f902c
docs: add comment explaining getDecodersForVm parameter
tansawit Nov 3, 2025
65e1f49
Update README.md
tansawit Nov 3, 2025
14b7fab
Fix NFT burn test by adding mock for burned token address
tansawit Nov 3, 2025
15dce51
feat: fix readme spacing
tansawit Nov 3, 2025
1baf8f7
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Nov 4, 2025
b2c5597
Merge remote-tracking branch 'origin/main' into feat-add-authz-feegra…
tansawit Nov 10, 2025
2148565
impv: cleanup comments and update authz exec test case transaction
tansawit Nov 10, 2025
2d06910
impv: remove duplicate authz test case
tansawit Nov 10, 2025
837713f
impv: move authz test fixtures to fixtures file
tansawit Nov 10, 2025
68c2276
Merge branch 'main' of github.com:initia-labs/tx-decoder
Poafs1 Nov 17, 2025
d676ee6
refactor: consolidate authz decoders into a single array for better m…
evilpeach Nov 18, 2025
be239e5
Merge branch 'main' into feat-add-authz-feegrant-support
Poafs1 Nov 18, 2025
ccc5440
fix: pr comments
Poafs1 Nov 18, 2025
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,19 @@ The decoder returns a structured object with the following format:
- `/initia.mstaking.v1.MsgUndelegate`
- `/initia.mstaking.v1.MsgBeginRedelegate`

#### Authz Messages

- `/cosmos.authz.v1beta1.MsgExec` - Execute authorized messages on behalf of another account
- `/cosmos.authz.v1beta1.MsgGrant` - Grant authorization to another account
- `/cosmos.authz.v1beta1.MsgRevoke` - Revoke a previously granted authorization

#### Feegrant Messages

- `/cosmos.feegrant.v1beta1.MsgGrantAllowance` - Grant a fee allowance to another account
- `/cosmos.feegrant.v1beta1.MsgRevokeAllowance` - Revoke an existing fee allowance

**Note:** MsgExec recursively decodes the inner messages it contains. All currently supported message types can be wrapped within MsgExec and will be properly decoded.

### Move VM Messages

- `/initia.move.v1.MsgExecute`
Expand Down
11 changes: 11 additions & 0 deletions src/decoder-registry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import * as Decoders from "./decoders";
import { MessageDecoder } from "./interfaces";

const cosmosAuthDecoders: MessageDecoder[] = [
Decoders.authzExecDecoder,
Decoders.authzGrantDecoder,
Decoders.authzRevokeDecoder,
Decoders.feegrantGrantAllowanceDecoder,
Decoders.feegrantRevokeAllowanceDecoder
];

export const cosmosEvmMessageDecoders: MessageDecoder[] = [
...cosmosAuthDecoders,
Decoders.sendDecoder,
Decoders.finalizeTokenDepositDecoder,
Decoders.initiateTokenWithdrawalDecoder,
Expand All @@ -12,6 +21,7 @@ export const cosmosEvmMessageDecoders: MessageDecoder[] = [
];

export const cosmosWasmMessageDecoders: MessageDecoder[] = [
...cosmosAuthDecoders,
Decoders.sendDecoder,
Decoders.initiateTokenWithdrawalDecoder,
Decoders.finalizeTokenDepositDecoder,
Expand All @@ -28,6 +38,7 @@ export const cosmosWasmMessageDecoders: MessageDecoder[] = [
];

export const cosmosMoveMessageDecoders: MessageDecoder[] = [
...cosmosAuthDecoders,
Decoders.claimMinitswapDecoder,
Decoders.delegateDecoder,
Decoders.delegateLockedDecoder,
Expand Down
45 changes: 16 additions & 29 deletions src/decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ export class TxDecoder {
): Promise<DecodedEthereumTx> {
const ethereumPayload = validateAndPrepareEthereumPayload(payload);

// PRE-CHECK: Is this a mirrored Cosmos transaction?
const cosmosTxHash = extractCosmosTxHashFromEvm(ethereumPayload);
if (cosmosTxHash) {
try {
Expand All @@ -167,7 +166,6 @@ export class TxDecoder {
}
}

// Regular Ethereum transaction flow
const decoder = this._findEthereumDecoder(ethereumPayload);

const balanceChanges = await calculateBalanceChangesFromEthereumLogs(
Expand Down Expand Up @@ -221,28 +219,13 @@ export class TxDecoder {
): ReturnType<MessageDecoder["decode"]> {
const notSupportedMessage = createNotSupportedMessage(message["@type"]);

// For failed transactions (code !== 0), logs array is empty
// Create a synthetic log from txResponse events to allow decoders to process the message
const effectiveLog: Log = log || {
events: txResponse.events,
log: txResponse.raw_log,
msg_index: messageIndex
};

let decoders;
switch (vm) {
case "evm":
decoders = cosmosEvmMessageDecoders;
break;
case "move":
decoders = cosmosMoveMessageDecoders;
break;
case "wasm":
decoders = cosmosWasmMessageDecoders;
break;
default:
throw new Error(`Unknown VM type: ${vm}`);
}
const decoders = this._getDecodersForVm(vm);

try {
const decoder = this._findDecoderForMessage(
Expand All @@ -258,32 +241,24 @@ export class TxDecoder {
effectiveLog,
this.apiClient,
txResponse,
vm
vm,
this._getDecodersForVm.bind(this)
);
} catch (e) {
console.error(e);
return notSupportedMessage;
}
}

/**
* Decodes a mirrored Cosmos transaction by fetching and decoding the original Cosmos tx.
*
* Returns only the cosmos messages in the data field to avoid duplication.
* Metadata and totalBalanceChanges are at the root level only.
*/
private async _decodeMirroredCosmosTx(
cosmosTxHash: string,
ethereumPayload: EthereumRpcPayload
): Promise<DecodedEthereumTx> {
// Fetch the Cosmos transaction from REST API
const cosmosTxResponse = await this.apiClient.getCosmosTx(cosmosTxHash);

// Decode it using the Cosmos transaction decoder
const decodedCosmosEvmTx =
await this.decodeCosmosEvmTransaction(cosmosTxResponse);

// Return in cosmos_mirror format
return {
decodedTransaction: {
action: "cosmos_mirror",
Expand All @@ -293,7 +268,6 @@ export class TxDecoder {
evmTxHash: ethereumPayload.tx.hash
}
},
// Metadata and balance changes from the decoded Cosmos transaction
metadata: decodedCosmosEvmTx.metadata,
totalBalanceChanges: decodedCosmosEvmTx.totalBalanceChanges
};
Expand All @@ -312,6 +286,19 @@ export class TxDecoder {
return ethereumDecoders.find((decoder) => decoder.check(payload));
}

private _getDecodersForVm(vm: VmType): MessageDecoder[] {
switch (vm) {
case "evm":
return cosmosEvmMessageDecoders;
case "move":
return cosmosMoveMessageDecoders;
case "wasm":
return cosmosWasmMessageDecoders;
default:
throw new Error(`Unknown VM type: ${vm}`);
}
}

private async _processMessages(
txResponse: TxResponse,
vm: VmType
Expand Down
144 changes: 144 additions & 0 deletions src/decoders/cosmos/authz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { ApiClient } from "@/api";
import type { DecodedMessage, MessageDecoder, VmType } from "@/interfaces";

import { SUPPORTED_MESSAGE_TYPES } from "@/message-types";
import {
Log,
Message,
TxResponse,
zMsgExec,
zMsgGrant,
zMsgRevoke
} from "@/schema";
import { createNotSupportedMessage } from "@/utils";

export const authzExecDecoder: MessageDecoder = {
check: (message, _log) =>
message["@type"] === SUPPORTED_MESSAGE_TYPES.MsgExec,
decode: async (
message: Message,
log: Log,
apiClient: ApiClient,
txResponse: TxResponse,
vm: VmType,
getDecodersForVm?: (vm: VmType) => MessageDecoder[]
) => {
const parsed = zMsgExec.safeParse(message);
if (!parsed.success) {
throw new Error("Invalid authz exec message");
}

const { grantee, msgs } = parsed.data;

if (!getDecodersForVm) {
const decodedMessage: DecodedMessage = {
action: "authz_exec",
data: {
grantee,
messages: msgs.map((msg) => createNotSupportedMessage(msg["@type"]))
},
isIbc: false,
isOp: false
};
return decodedMessage;
}

const decoders = getDecodersForVm(vm);

const decodedInnerMessages: DecodedMessage[] = [];
for (const innerMessage of msgs) {
const decoder = decoders.find((d) => d.check(innerMessage, log, vm));

if (decoder) {
const decoded = await decoder.decode(
innerMessage,
log,
apiClient,
txResponse,
vm,
getDecodersForVm
);
decodedInnerMessages.push(decoded);
} else {
decodedInnerMessages.push(
createNotSupportedMessage(innerMessage["@type"])
);
}
}

const decodedMessage: DecodedMessage = {
action: "authz_exec",
data: {
grantee,
messages: decodedInnerMessages
},
isIbc: false,
isOp: false
};

return decodedMessage;
}
};

export const authzGrantDecoder: MessageDecoder = {
check: (message, _log) =>
message["@type"] === SUPPORTED_MESSAGE_TYPES.MsgGrant,
decode: async (
message: Message,
_log: Log,
_apiClient: ApiClient,
_txResponse: TxResponse
) => {
const parsed = zMsgGrant.safeParse(message);
if (!parsed.success) {
throw new Error("Invalid authz grant message");
}

const { grant, grantee, granter } = parsed.data;

const decodedMessage: DecodedMessage = {
action: "authz_grant",
data: {
authorization: grant.authorization,
expiration: grant.expiration,
grantee,
granter
},
isIbc: false,
isOp: false
};

return decodedMessage;
}
};

export const authzRevokeDecoder: MessageDecoder = {
check: (message, _log) =>
message["@type"] === SUPPORTED_MESSAGE_TYPES.MsgRevoke,
decode: async (
message: Message,
_log: Log,
_apiClient: ApiClient,
_txResponse: TxResponse
) => {
const parsed = zMsgRevoke.safeParse(message);
if (!parsed.success) {
throw new Error("Invalid authz revoke message");
}

const { grantee, granter, msg_type_url } = parsed.data;

const decodedMessage: DecodedMessage = {
action: "authz_revoke",
data: {
grantee,
granter,
msg_type_url
},
isIbc: false,
isOp: false
};

return decodedMessage;
}
};
72 changes: 72 additions & 0 deletions src/decoders/cosmos/feegrant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ApiClient } from "@/api";
import type { DecodedMessage, MessageDecoder } from "@/interfaces";

import { SUPPORTED_MESSAGE_TYPES } from "@/message-types";
import {
Log,
Message,
TxResponse,
zMsgGrantAllowance,
zMsgRevokeAllowance
} from "@/schema";

export const feegrantGrantAllowanceDecoder: MessageDecoder = {
check: (message, _log) =>
message["@type"] === SUPPORTED_MESSAGE_TYPES.MsgGrantAllowance,
decode: async (
message: Message,
_log: Log,
_apiClient: ApiClient,
_txResponse: TxResponse
) => {
const parsed = zMsgGrantAllowance.safeParse(message);
if (!parsed.success) {
throw new Error("Invalid feegrant grant allowance message");
}

const { allowance, grantee, granter } = parsed.data;

const decodedMessage: DecodedMessage = {
action: "feegrant_grant_allowance",
data: {
allowance,
grantee,
granter
},
isIbc: false,
isOp: false
};

return decodedMessage;
}
};

export const feegrantRevokeAllowanceDecoder: MessageDecoder = {
check: (message, _log) =>
message["@type"] === SUPPORTED_MESSAGE_TYPES.MsgRevokeAllowance,
decode: async (
message: Message,
_log: Log,
_apiClient: ApiClient,
_txResponse: TxResponse
) => {
const parsed = zMsgRevokeAllowance.safeParse(message);
if (!parsed.success) {
throw new Error("Invalid feegrant revoke allowance message");
}

const { grantee, granter } = parsed.data;

const decodedMessage: DecodedMessage = {
action: "feegrant_revoke_allowance",
data: {
grantee,
granter
},
isIbc: false,
isOp: false
};

return decodedMessage;
}
};
2 changes: 2 additions & 0 deletions src/decoders/cosmos/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from "./authz";
export * from "./bank";
export * from "./distribution";
export * from "./feegrant";
export * from "./move";
export * from "./mstaking";
export * from "./wasm";
Loading
Loading