Skip to content
Open
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
5 changes: 5 additions & 0 deletions modules/cosmos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"version": "1.0.0",
"description": "",
"main": "index.js",
"exports": {
"./gov": "./src/modules/gov/index.ts",
"./ethermint": "./src/modules/ethermint/index.ts",
"./query": "./src/modules/query/index.ts"
},
"scripts": {
"lint": "eslint .",
"test:mainnet": "cp configs/mainnet.module.config.json module.config.json && mocha",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Account, accountFromAny } from "@cosmjs/stargate";
import { BaseAccount } from "cosmjs-types/cosmos/auth/v1beta1/auth";
import { Any } from "cosmjs-types/google/protobuf/any";
import { parseEthAccount } from "./parser";

Expand All @@ -18,28 +19,31 @@ import { parseEthAccount } from "./parser";
* @throws Error if parsing fails for both EthAccount and standard account formats.
*/
export function ethermintAccountParser(input: Any): Account {
try {
// Handle EthAccount specifically
if (input.typeUrl === "/ethermint.types.v1.EthAccount") {
const ethAccount = parseEthAccount(input);
if (ethAccount?.baseAccount) {
return {
address: ethAccount.baseAccount.address,
accountNumber: Number(ethAccount.baseAccount.accountNumber),
sequence: Number(ethAccount.baseAccount.sequence),
pubkey: null, // EthAccount doesn't store pubkey in the account
} as Account;
}
// If EthAccount parsing fails, fall through to standard parsing
if (input.typeUrl === "/ethermint.types.v1.EthAccount") {
const ethAccount = parseEthAccount(input);
if (ethAccount?.baseAccount) {
return {
address: ethAccount.baseAccount.address,
accountNumber: Number(ethAccount.baseAccount.accountNumber),
sequence: Number(ethAccount.baseAccount.sequence),
pubkey: null,
} as Account;
}
}

// For all other account types or if EthAccount parsing failed, use standard parsing
return accountFromAny(input);
} catch (error) {
console.error("Failed to parse account with ethermintAccountParser:", error);
// Final fallback to standard parsing, let it throw if it fails
return accountFromAny(input);
// BaseAccount may carry a non-cosmos pubkey (e.g. ethsecp256k1) that accountFromAny cannot decode.
// Decode fields directly and drop the pubkey to avoid the failure.
if (input.typeUrl === "/cosmos.auth.v1beta1.BaseAccount") {
const base = BaseAccount.decode(input.value);
return {
address: base.address,
accountNumber: Number(base.accountNumber),
sequence: Number(base.sequence),
pubkey: null,
} as Account;
}

return accountFromAny(input);
}

/**
Expand Down
7 changes: 7 additions & 0 deletions modules/cosmos/src/modules/ethermint/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from "./account-parser";
export * from "./parser";
export * from "./pubkey";
export * from "./secp256k1";
export * from "./signer";
export * from "./signing-client";
export * from "./signing-stargate-client";
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
Random,
Secp256k1,
Secp256k1Keypair,
sha256,
Slip10,
Slip10Curve,
stringToPath,
Expand Down Expand Up @@ -281,7 +280,7 @@ export class DirectSecp256k1HdWallet implements OfflineDirectSigner {
}
const { privkey, pubkey } = account;
const signBytes = makeSignBytes(signDoc);
const hashedMessage = sha256(signBytes);
const hashedMessage = keccak256(signBytes);
const signature = await Secp256k1.createSignature(hashedMessage, privkey);
const signatureBytes = new Uint8Array([...signature.r(32), ...signature.s(32)]);
const stdSignature = encodeSecp256k1Signature(pubkey, signatureBytes, true); // true for Ethermint
Expand Down
110 changes: 110 additions & 0 deletions modules/cosmos/src/modules/ethermint/signing-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Account, HttpEndpoint, SigningStargateClientOptions, defaultRegistryTypes } from "@cosmjs/stargate";
import { OfflineSigner, Registry } from "@cosmjs/proto-signing";
import { CometClient, connectComet } from "@cosmjs/tendermint-rpc";
import { createEthermintAccountParser, ethermintAccountParser } from "./account-parser";
import { SigningStargateClient } from "./signing-stargate-client";

/**
* Create Ethermint-compatible registry with correct public key types.
* @returns Registry configured for Ethermint chains.
*/
function createEthermintRegistry(): Registry {
const registry = new Registry(defaultRegistryTypes);

// Register Ethermint-specific amino types
registry.register("/ethermint.crypto.v1.ethsecp256k1.PubKey", {} as any);
registry.register("/ethermint.types.v1.EthAccount", {} as any);

return registry;
}

type EthermintClientCtor<T extends EthermintSigningClient> = new (
cometClient: CometClient,
signer: OfflineSigner,
options: SigningStargateClientOptions,
) => T;

/**
* Ethermint-aware signing client. Uses ethermint pubkey/account types and
* unwraps EthAccount on account queries.
*/
export class EthermintSigningClient extends SigningStargateClient {
/**
* Widens the parent's protected constructor to public so subclasses can be
* instantiated via `new this(...)` from the static factories below.
* @param cometClient The comet client.
* @param signer The signer.
* @param options The client options.
*/
constructor(cometClient: CometClient, signer: OfflineSigner, options: SigningStargateClientOptions) {
super(cometClient, signer, options);
}

/**
* Get the account.
* @param searchAddress The address to search for.
* @returns The account, or null if not found.
*/
async getAccount(searchAddress: string): Promise<Account | null> {
try {
const accountAny = await this.forceGetQueryClient().auth.account(searchAddress);
if (!accountAny) {
return null;
}
return ethermintAccountParser(accountAny);
} catch (error) {
console.error("Failed to get account:", error);
return null;
}
}

/**
* Run a raw ABCI query against the connected chain.
* @param path The ABCI query path (e.g. "/cosmos.gov.v1.Query/Proposal").
* @param data The encoded request bytes.
* @returns The raw response value bytes.
*/
async queryAbci(path: string, data: Uint8Array): Promise<Uint8Array> {
const { value } = await this.forceGetQueryClient().queryAbci(path, data);
return value;
}

/**
* Create a client with a signer.
* @param cometClient The comet client.
* @param signer The signer.
* @param options The options.
* @returns The client.
*/
static async createWithSigner<T extends EthermintSigningClient>(
this: EthermintClientCtor<T>,
cometClient: CometClient,
signer: OfflineSigner,
options?: SigningStargateClientOptions,
): Promise<T> {
const defaultOptions: SigningStargateClientOptions = {
accountParser: createEthermintAccountParser(),
registry: createEthermintRegistry(),
aminoTypes: undefined,
...options,
};
return new this(cometClient, signer, defaultOptions);
}

/**
* Connect with a signer.
* @param endpoint The endpoint.
* @param signer The signer.
* @param options The options.
* @returns The client.
*/
static async connectWithSigner<T extends EthermintSigningClient>(
this: EthermintClientCtor<T>,
endpoint: string | HttpEndpoint,
signer: OfflineSigner,
options?: SigningStargateClientOptions,
): Promise<T> {
const cometClient = await connectComet(endpoint);
return EthermintSigningClient.createWithSigner.call(this, cometClient, signer, options) as Promise<T>;
}
}
138 changes: 138 additions & 0 deletions modules/cosmos/src/modules/gov/gov.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { stringToPath } from "@cosmjs/crypto";
import { MsgSubmitProposal, MsgSubmitProposalResponse, MsgVote } from "cosmjs-types/cosmos/gov/v1/tx";
import { QueryProposalRequest, QueryProposalResponse } from "cosmjs-types/cosmos/gov/v1/query";
import { ProposalStatus, VoteOption } from "cosmjs-types/cosmos/gov/v1/gov";
import { Any } from "cosmjs-types/google/protobuf/any";
import { DirectSecp256k1HdWallet, EthermintSigningClient } from "../ethermint";

export { ProposalStatus } from "cosmjs-types/cosmos/gov/v1/gov";

const ETHEREUM_HD_PATH = "m/44'/60'/0'/0/0";
const SUBMIT_PROPOSAL_GAS = "500000";
const VOTE_GAS = "200000";
const PROPOSAL_POLL_INTERVAL_MS = 2_000;
const PROPOSAL_DEFAULT_TIMEOUT_MS = 30_000;

export type SubmitAndVoteParams = {
rpcUrl: string;
mnemonic: string;
prefix: string;
denom: string;
message: Any;
title: string;
summary: string;
depositAmount: string;
timeoutMs?: number;
};

/**
* Sleep for the given duration.
* @param ms Duration in milliseconds.
*/
async function sleep(ms: number): Promise<void> {
await new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
}

/**
* Poll the chain until a proposal reaches a terminal status (>= PASSED).
* @param client Connected Ethermint signing client.
* @param proposalId Proposal identifier.
* @param timeoutMs Timeout in milliseconds.
* @returns The terminal proposal status code.
*/
async function waitForProposal(client: EthermintSigningClient, proposalId: bigint, timeoutMs: number): Promise<number> {
const deadline = Date.now() + timeoutMs;
const request = QueryProposalRequest.encode(QueryProposalRequest.fromPartial({ proposalId })).finish();

while (Date.now() < deadline) {
const value = await client.queryAbci("/cosmos.gov.v1.Query/Proposal", request);
if (value.length > 0) {
const { proposal } = QueryProposalResponse.decode(value);
const status = proposal ? Number(proposal.status) : undefined;
if (typeof status === "number" && status >= ProposalStatus.PROPOSAL_STATUS_PASSED) {
return status;
}
}

await sleep(PROPOSAL_POLL_INTERVAL_MS);
}

throw new Error(`Proposal ${proposalId} did not reach terminal state within ${timeoutMs}ms`);
}

/**
* Submit a single-message governance proposal, vote YES from the same sender, and wait for a terminal status.
*
* The `depositAmount` must be >= the chain's `min_deposit` (gov params), otherwise the proposal stays
* in deposit period and `waitForProposal` times out.
* @param params Submit-and-vote parameters.
* @returns Proposal id and terminal status.
*/
export async function submitAndVote(params: SubmitAndVoteParams): Promise<{ proposalId: string; status: number }> {
const { rpcUrl, mnemonic, prefix, denom, message, title, summary, depositAmount, timeoutMs = PROPOSAL_DEFAULT_TIMEOUT_MS } = params;

const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
prefix,
hdPaths: [stringToPath(ETHEREUM_HD_PATH)],
});
const client = await EthermintSigningClient.connectWithSigner(rpcUrl, wallet);

try {
const [{ address: sender }] = await wallet.getAccounts();

const submitResult = await client.signAndBroadcast(
sender,
[
{
typeUrl: "/cosmos.gov.v1.MsgSubmitProposal",
value: MsgSubmitProposal.fromPartial({
messages: [message],
initialDeposit: [{ denom, amount: depositAmount }],
proposer: sender,
metadata: "",
title,
summary,
}),
},
],
{ amount: [{ denom, amount: "1" }], gas: SUBMIT_PROPOSAL_GAS },
);
if (submitResult.code !== 0) {
throw new Error(`Submit proposal failed: ${submitResult.rawLog ?? `code=${submitResult.code}`}`);
}

const [msgResponse] = submitResult.msgResponses;
if (!msgResponse) {
throw new Error("MsgSubmitProposalResponse missing from submit result");
}
const proposalId = MsgSubmitProposalResponse.decode(msgResponse.value).proposalId;

const voteResult = await client.signAndBroadcast(
sender,
[
{
typeUrl: "/cosmos.gov.v1.MsgVote",
value: MsgVote.fromPartial({
proposalId,
voter: sender,
option: VoteOption.VOTE_OPTION_YES,
metadata: "",
}),
},
],
{ amount: [{ denom, amount: "1" }], gas: VOTE_GAS },
);
if (voteResult.code !== 0) {
throw new Error(`Vote failed: ${voteResult.rawLog ?? `code=${voteResult.code}`}`);
}

return {
proposalId: proposalId.toString(),
status: await waitForProposal(client, proposalId, timeoutMs),
};
} finally {
client.disconnect();
}
}
1 change: 1 addition & 0 deletions modules/cosmos/src/modules/gov/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./gov";
Loading