Repository: hyperlane-xyz/hyperlane-monorepo
Pull request #5862: test: add cosmos-sdk e2e tests
Author: login: troykessler
Created: 2025-04-08T14:43:13Z | Merged: 2025-04-15T07:08:53Z

### Description

This PR adds e2e tests for the cosmos-sdk. It spawns a local cosmos chain inside a docker container with just the Hyperlane Module installed while transactions are submitted with the cosmos-sdk. It is asserted if the transactions succeed and if there are no errors.

### Drive-by changes

-

### Related issues

-

### Backward compatibility

Yes

### Testing

e2e testing



Diff:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a53caaa6bcb..e1a2d553764 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -172,6 +172,23 @@ jobs:
           echo "CLI E2E tests failed"
           exit 1
 
+  cosmos-sdk-e2e:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ github.event.pull_request.head.sha || github.sha }}
+          submodules: recursive
+          fetch-depth: 0
+
+      - name: yarn-build
+        uses: ./.github/actions/yarn-build-with-cache
+        with:
+          ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
+      - name: Cosmos SDK e2e tests
+        run: yarn --cwd typescript/cosmos-sdk test:e2e
+
   agent-configs:
     runs-on: ubuntu-latest
     strategy:
@@ -384,7 +401,6 @@ jobs:
       MAINNET3_ETHEREUM_RPC_URLS: ${{ secrets.MAINNET3_ETHEREUM_RPC_URLS }}
       TESTNET4_SEPOLIA_RPC_URLS: ${{ secrets.TESTNET4_SEPOLIA_RPC_URLS }}
 
-
     timeout-minutes: 10
     strategy:
       fail-fast: false
diff --git a/typescript/cosmos-sdk/.mocharc-e2e.json b/typescript/cosmos-sdk/.mocharc-e2e.json
new file mode 100644
index 00000000000..44c560068b3
--- /dev/null
+++ b/typescript/cosmos-sdk/.mocharc-e2e.json
@@ -0,0 +1,8 @@
+{
+  "extensions": ["ts"],
+  "spec": ["src/tests/index.e2e-test.ts"],
+  "node-option": [
+    "experimental-specifier-resolution=node",
+    "loader=ts-node/esm"
+  ]
+}
diff --git a/typescript/cosmos-sdk/Dockerfile b/typescript/cosmos-sdk/Dockerfile
new file mode 100644
index 00000000000..28d6e6a382c
--- /dev/null
+++ b/typescript/cosmos-sdk/Dockerfile
@@ -0,0 +1,22 @@
+FROM golang:1.22
+
+WORKDIR /app
+
+# install latest updates
+RUN apt update && apt upgrade -y
+
+# install hypd
+RUN git clone --depth 1 --branch v1.0.0-beta0 https://github.com/bcp-innovations/hyperlane-cosmos.git \
+    && cd hyperlane-cosmos \
+    && make build-simapp \
+    && mv build/hypd /app \
+    && /app/hypd init-sample-chain \
+    && cd .. \
+    && rm -r hyperlane-cosmos
+
+# rpc
+EXPOSE 26657
+# api
+EXPOSE 1317
+
+CMD ["/app/hypd", "start"]
diff --git a/typescript/cosmos-sdk/compose.yaml b/typescript/cosmos-sdk/compose.yaml
new file mode 100644
index 00000000000..c66732413e3
--- /dev/null
+++ b/typescript/cosmos-sdk/compose.yaml
@@ -0,0 +1,6 @@
+services:
+  hyperlane-cosmos-simapp:
+    build: .
+    ports:
+      - 26657:26657
+      - 1317:1317
diff --git a/typescript/cosmos-sdk/eslint.config.mjs b/typescript/cosmos-sdk/eslint.config.mjs
index 5be809affb9..18ef8a9fa54 100644
--- a/typescript/cosmos-sdk/eslint.config.mjs
+++ b/typescript/cosmos-sdk/eslint.config.mjs
@@ -5,4 +5,7 @@ export default [
   {
     files: ['src/**/*.ts'],
   },
+  {
+    ignores: ['src/tests/**/*.ts'],
+  },
 ];
diff --git a/typescript/cosmos-sdk/package.json b/typescript/cosmos-sdk/package.json
index d3585522ad2..74993fd98c0 100644
--- a/typescript/cosmos-sdk/package.json
+++ b/typescript/cosmos-sdk/package.json
@@ -26,16 +26,20 @@
     "prettier": "prettier --write ./src",
     "clean": "rm -rf ./dist ./cache",
     "test": "echo \"no tests in cosmos-sdk\"",
-    "test:ci": "echo \"no tests in cosmos-sdk\""
+    "test:ci": "echo \"no tests in cosmos-sdk\"",
+    "test:e2e": "./scripts/run-e2e-test.sh"
   },
   "devDependencies": {
     "@eslint/js": "^9.15.0",
+    "@types/mocha": "^10.0.1",
     "@typescript-eslint/eslint-plugin": "^8.1.6",
     "@typescript-eslint/parser": "^8.1.6",
     "eslint": "^9.15.0",
     "eslint-config-prettier": "^9.1.0",
     "eslint-import-resolver-typescript": "^3.6.3",
     "eslint-plugin-import": "^2.31.0",
+    "mocha": "^10.2.0",
+    "mocha-steps": "^1.3.0",
     "prettier": "^2.8.8",
     "typescript": "5.3.3",
     "typescript-eslint": "^8.23.0"
diff --git a/typescript/cosmos-sdk/scripts/run-e2e-test.sh b/typescript/cosmos-sdk/scripts/run-e2e-test.sh
new file mode 100755
index 00000000000..962caa02589
--- /dev/null
+++ b/typescript/cosmos-sdk/scripts/run-e2e-test.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+function cleanup() {
+  docker compose down
+}
+
+cleanup
+
+echo "Preparing E2E tests"
+docker compose up --detach --wait
+
+if [[ $? -ne 0 ]]; then
+  echo "Failure starting local cosmos chain"
+  exit 1
+fi
+
+echo "Running E2E tests"
+yarn mocha --config .mocharc-e2e.json
+
+cleanup
+
+echo "Completed E2E tests"
diff --git a/typescript/cosmos-sdk/src/tests/1_interchain_security.e2e-test.ts b/typescript/cosmos-sdk/src/tests/1_interchain_security.e2e-test.ts
new file mode 100644
index 00000000000..576486b57c8
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/1_interchain_security.e2e-test.ts
@@ -0,0 +1,166 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import {
+  MerkleRootMultisigISM,
+  MessageIdMultisigISM,
+} from '../../../cosmos-types/dist/types/hyperlane/core/interchain_security/v1/types.js';
+import {
+  bytes32ToAddress,
+  isValidAddressEvm,
+} from '../../../utils/dist/addresses.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('1. cosmos sdk interchain security e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new NOOP ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.be.empty;
+
+    // ACT
+    const txResponse = await signer.createNoopIsm({});
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const noopIsm = txResponse.response;
+
+    expect(noopIsm.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(noopIsm.id))).to.be.true;
+
+    isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    let ism = await signer.query.interchainSecurity.Ism({
+      id: noopIsm.id,
+    });
+    expect(ism.ism?.type_url).to.equal(
+      '/hyperlane.core.interchain_security.v1.NoopISM',
+    );
+
+    let decodedIsm = await signer.query.interchainSecurity.DecodedIsm({
+      id: noopIsm.id,
+    });
+    expect(decodedIsm.ism.id).to.equal(noopIsm.id);
+    expect(decodedIsm.ism.owner).to.equal(signer.account.address);
+  });
+
+  step('create new MessageIdMultisig ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    const threshold = 2;
+    const validators = [
+      '0x3C24F29fa75869A1C9D19d9d6589Aae0B5227c3c',
+      '0xf719b4CC64d0E3a380e52c2720Abab13835F6d9c',
+      '0x98A56EdE1d6Dd386216DA8217D9ac1d2EE7c27c7',
+    ];
+
+    // note that the validators need to be sorted alphabetically
+    validators.sort();
+
+    // ACT
+    const txResponse = await signer.createMessageIdMultisigIsm({
+      validators,
+      threshold,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const messageIdIsm = txResponse.response;
+
+    expect(messageIdIsm.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(messageIdIsm.id))).to.be.true;
+
+    isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(2);
+
+    let ism = await signer.query.interchainSecurity.Ism({
+      id: messageIdIsm.id,
+    });
+    expect(ism.ism?.type_url).to.equal(
+      '/hyperlane.core.interchain_security.v1.MessageIdMultisigISM',
+    );
+
+    let decodedIsm = await signer.query.interchainSecurity.DecodedIsm({
+      id: messageIdIsm.id,
+    });
+
+    expect(decodedIsm.ism.id).to.equal(messageIdIsm.id);
+    expect(decodedIsm.ism.owner).to.equal(signer.account.address);
+
+    expect((decodedIsm.ism as MessageIdMultisigISM).threshold).to.equal(
+      threshold,
+    );
+    expect((decodedIsm.ism as MessageIdMultisigISM).validators).deep.equal(
+      validators,
+    );
+  });
+
+  step('create new MerkleRootMultisig ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(2);
+
+    const threshold = 3;
+    const validators = [
+      '0x0264258613775932aA466Be8BcC62418a9558eaB',
+      '0x829d3Cc78Fd664Bf160A17DaEad4df943ff7bAf0',
+      '0x3177Cc7328dE71Da934b1b7BF04b55C7D7251A63',
+      '0x270dC7A054a2aeda93Ee38a1b3C0727f5d8252d3',
+    ];
+
+    // note that the validators need to be sorted alphabetically
+    validators.sort();
+
+    // ACT
+    const txResponse = await signer.createMerkleRootMultisigIsm({
+      validators,
+      threshold,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const merkleRootIsm = txResponse.response;
+
+    expect(merkleRootIsm.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(merkleRootIsm.id))).to.be.true;
+
+    isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(3);
+
+    let ism = await signer.query.interchainSecurity.Ism({
+      id: merkleRootIsm.id,
+    });
+    expect(ism.ism?.type_url).to.equal(
+      '/hyperlane.core.interchain_security.v1.MerkleRootMultisigISM',
+    );
+
+    let decodedIsm = await signer.query.interchainSecurity.DecodedIsm({
+      id: merkleRootIsm.id,
+    });
+
+    expect(decodedIsm.ism.id).to.equal(merkleRootIsm.id);
+    expect(decodedIsm.ism.owner).to.equal(signer.account.address);
+
+    expect((decodedIsm.ism as MerkleRootMultisigISM).threshold).to.equal(
+      threshold,
+    );
+    expect((decodedIsm.ism as MerkleRootMultisigISM).validators).deep.equal(
+      validators,
+    );
+  });
+});
diff --git a/typescript/cosmos-sdk/src/tests/2_core.e2e-test.ts b/typescript/cosmos-sdk/src/tests/2_core.e2e-test.ts
new file mode 100644
index 00000000000..5479d96edf4
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/2_core.e2e-test.ts
@@ -0,0 +1,159 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import {
+  bytes32ToAddress,
+  isValidAddressEvm,
+} from '../../../utils/dist/addresses.js';
+import { createAnnounce } from '../../../utils/src/validator.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('2. cosmos sdk core e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new mailbox', async () => {
+    // ARRANGE
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(0);
+
+    const { isms } = await signer.query.interchainSecurity.DecodedIsms({});
+    // take the Noop ISM
+    const ismId = isms[0].id;
+
+    const domainId = 1234;
+
+    // ACT
+    const txResponse = await signer.createMailbox({
+      local_domain: domainId,
+      default_ism: ismId,
+      default_hook: '',
+      required_hook: '',
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const mailbox = txResponse.response;
+
+    expect(mailbox.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(mailbox.id))).to.be.true;
+
+    mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(1);
+
+    let mailboxQuery = await signer.query.core.Mailbox({
+      id: mailbox.id,
+    });
+
+    expect(mailboxQuery.mailbox).not.to.be.undefined;
+    expect(mailboxQuery.mailbox?.id).to.equal(mailbox.id);
+    expect(mailboxQuery.mailbox?.owner).to.equal(signer.account.address);
+    expect(mailboxQuery.mailbox?.local_domain).to.equal(domainId);
+    expect(mailboxQuery.mailbox?.default_ism).to.equal(ismId);
+    expect(mailboxQuery.mailbox?.default_hook).to.be.empty;
+    expect(mailboxQuery.mailbox?.required_hook).to.be.empty;
+  });
+
+  step('set mailbox', async () => {
+    // ARRANGE
+    const newOwner = (await createSigner('bob')).account.address;
+
+    const domainId = 1234;
+
+    const { isms } = await signer.query.interchainSecurity.DecodedIsms({});
+    // this should be a noop ISM
+    const ismId = isms[0].id;
+
+    const createMailboxTxResponse = await signer.createMailbox({
+      local_domain: domainId,
+      default_ism: ismId,
+      default_hook: '',
+      required_hook: '',
+    });
+    expect(createMailboxTxResponse.code).to.equal(0);
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailboxBefore = mailboxes.mailboxes[mailboxes.mailboxes.length - 1];
+    expect(mailboxBefore.owner).to.equal(signer.account.address);
+
+    // ACT
+    const txResponse = await signer.setMailbox({
+      mailbox_id: mailboxBefore.id,
+      default_ism: '',
+      default_hook: '',
+      required_hook: '',
+      new_owner: newOwner,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailboxAfter = mailboxes.mailboxes[mailboxes.mailboxes.length - 1];
+
+    expect(mailboxAfter.id).to.equal(mailboxBefore.id);
+    expect(mailboxAfter.owner).to.equal(newOwner);
+    expect(mailboxAfter.local_domain).to.equal(mailboxBefore.local_domain);
+    expect(mailboxAfter.default_ism).to.equal(mailboxBefore.default_ism);
+    expect(mailboxAfter.default_hook).to.equal(mailboxBefore.default_hook);
+    expect(mailboxAfter.required_hook).to.equal(mailboxBefore.required_hook);
+  });
+
+  step('announce validator', async () => {
+    // ARRANGE
+    const validatorAddress = '0x0b1caf89d1edb9ee161093b1ec94ca75611db492';
+    const validatorPrivKey =
+      '38430941d3ea0e70f9a16192a833dbbf3541b3170781042067173bfe6cba4508';
+    const storageLocation = 'aws://key.pub';
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailbox = mailboxes.mailboxes[0];
+
+    const signature = await createAnnounce(
+      validatorPrivKey,
+      storageLocation,
+      mailbox.id,
+      mailbox.local_domain,
+    );
+
+    // ACT
+    const txResponse = await signer.announceValidator({
+      validator: validatorAddress,
+      storage_location: storageLocation,
+      signature,
+      mailbox_id: mailbox.id,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    let storageLocations =
+      await signer.query.interchainSecurity.AnnouncedStorageLocations({
+        mailbox_id: mailbox.id,
+        validator_address: validatorAddress,
+      });
+    expect(storageLocations.storage_locations).to.have.lengthOf(1);
+    expect(storageLocations.storage_locations[0]).to.equal(storageLocation);
+
+    let latestStorageLocation =
+      await signer.query.interchainSecurity.LatestAnnouncedStorageLocation({
+        mailbox_id: mailbox.id,
+        validator_address: validatorAddress,
+      });
+    expect(latestStorageLocation.storage_location).to.equal(storageLocation);
+  });
+});
diff --git a/typescript/cosmos-sdk/src/tests/3_post_dispatch.e2e-test.ts b/typescript/cosmos-sdk/src/tests/3_post_dispatch.e2e-test.ts
new file mode 100644
index 00000000000..f01365799f4
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/3_post_dispatch.e2e-test.ts
@@ -0,0 +1,282 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import {
+  bytes32ToAddress,
+  isValidAddressEvm,
+} from '../../../utils/dist/addresses.js';
+import { formatMessage, messageId } from '../../../utils/src/messages.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('3. cosmos sdk post dispatch e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new IGP hook', async () => {
+    // ARRANGE
+    let igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(0);
+
+    const denom = 'uhyp';
+
+    // ACT
+    const txResponse = await signer.createIgp({
+      denom,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const igp = txResponse.response;
+
+    expect(igp.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(igp.id))).to.be.true;
+
+    igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(1);
+
+    let igpQuery = await signer.query.postDispatch.Igp({
+      id: igp.id,
+    });
+
+    expect(igpQuery.igp).not.to.be.undefined;
+    expect(igpQuery.igp?.owner).to.equal(signer.account.address);
+    expect(igpQuery.igp?.denom).to.equal(denom);
+  });
+
+  step('create new Merkle Tree hook', async () => {
+    // ARRANGE
+    let merkleTrees = await signer.query.postDispatch.MerkleTreeHooks({});
+    expect(merkleTrees.merkle_tree_hooks).to.have.lengthOf(0);
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailbox = mailboxes.mailboxes[0];
+
+    // ACT
+    const txResponse = await signer.createMerkleTreeHook({
+      mailbox_id: mailbox.id,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const merleTree = txResponse.response;
+
+    expect(merleTree.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(merleTree.id))).to.be.true;
+
+    merkleTrees = await signer.query.postDispatch.MerkleTreeHooks({});
+    expect(merkleTrees.merkle_tree_hooks).to.have.lengthOf(1);
+
+    let merkleTreeQuery = await signer.query.postDispatch.MerkleTreeHook({
+      id: merleTree.id,
+    });
+
+    expect(merkleTreeQuery.merkle_tree_hook).not.to.be.undefined;
+    expect(merkleTreeQuery.merkle_tree_hook?.owner).to.equal(
+      signer.account.address,
+    );
+    expect(merkleTreeQuery.merkle_tree_hook?.mailbox_id).to.equal(mailbox.id);
+  });
+
+  step('create new Noop hook', async () => {
+    // ARRANGE
+    let noopHooks = await signer.query.postDispatch.NoopHooks({});
+    expect(noopHooks.noop_hooks).to.have.lengthOf(0);
+
+    // ACT
+    const txResponse = await signer.createNoopHook({});
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const noopHook = txResponse.response;
+
+    expect(noopHook.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(noopHook.id))).to.be.true;
+
+    noopHooks = await signer.query.postDispatch.NoopHooks({});
+    expect(noopHooks.noop_hooks).to.have.lengthOf(1);
+
+    let noopHookQuery = await signer.query.postDispatch.NoopHook({
+      id: noopHook.id,
+    });
+
+    expect(noopHookQuery.noop_hook).not.to.be.undefined;
+    expect(noopHookQuery.noop_hook?.owner).to.equal(signer.account.address);
+  });
+
+  step('set destination gas config', async () => {
+    // ARRANGE
+    let igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(1);
+
+    const igp = igps.igps[0];
+    const remoteDomainId = 1234;
+    const gasOverhead = '200000';
+    const gasPrice = '1';
+    const tokenExchangeRate = '10000000000';
+
+    let gasConfigs = await signer.query.postDispatch.DestinationGasConfigs({
+      id: igp.id,
+    });
+    expect(gasConfigs.destination_gas_configs).to.have.lengthOf(0);
+
+    // ACT
+    const txResponse = await signer.setDestinationGasConfig({
+      igp_id: igp.id,
+      destination_gas_config: {
+        remote_domain: remoteDomainId,
+        gas_oracle: {
+          token_exchange_rate: tokenExchangeRate,
+          gas_price: gasPrice,
+        },
+        gas_overhead: gasOverhead,
+      },
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    gasConfigs = await signer.query.postDispatch.DestinationGasConfigs({
+      id: igp.id,
+    });
+    expect(gasConfigs.destination_gas_configs).to.have.lengthOf(1);
+
+    const gasConfig = gasConfigs.destination_gas_configs[0];
+
+    expect(gasConfig.remote_domain).to.equal(remoteDomainId);
+    expect(gasConfig.gas_overhead).to.equal(gasOverhead);
+    expect(gasConfig.gas_oracle?.gas_price).to.equal(gasPrice);
+    expect(gasConfig.gas_oracle?.token_exchange_rate).to.equal(
+      tokenExchangeRate,
+    );
+  });
+
+  step('pay for gas', async () => {
+    // ARRANGE
+    const address = '0xA56009c72c0191a1D56e2feA5Bd8250707FF1874';
+    const destinationDomainId = 1234;
+    const denom = 'uhyp';
+    const amount = {
+      denom,
+      amount: '1000000',
+    };
+
+    const igpCreateTxResponse = await signer.createIgp({
+      denom,
+    });
+    expect(igpCreateTxResponse.code).to.equal(0);
+
+    let igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpBefore = igps.igps[igps.igps.length - 1];
+    expect(igpBefore.claimable_fees).to.be.empty;
+
+    const testMessageId = messageId(
+      formatMessage(
+        1,
+        0,
+        destinationDomainId,
+        address,
+        destinationDomainId,
+        address,
+        '0x1234',
+      ),
+    );
+
+    // ACT
+    const txResponse = await signer.payForGas({
+      igp_id: igpBefore.id,
+      message_id: testMessageId,
+      destination_domain: destinationDomainId,
+      gas_limit: '10000',
+      amount,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpAfter = igps.igps[igps.igps.length - 1];
+
+    expect(igpAfter.id).to.equal(igpBefore.id);
+    expect(igpAfter.denom).to.equal(igpBefore.denom);
+    expect(igpAfter.claimable_fees).to.have.lengthOf(1);
+    expect(igpAfter.claimable_fees[0]).deep.equal(amount);
+  });
+
+  step('claim', async () => {
+    // ARRANGE
+    const denom = 'uhyp';
+    const amount = {
+      denom,
+      amount: '1000000',
+    };
+
+    let igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpBefore = igps.igps[igps.igps.length - 1];
+    expect(igpBefore.claimable_fees).to.have.lengthOf(1);
+    expect(igpBefore.claimable_fees[0]).deep.equal(amount);
+
+    // ACT
+    const txResponse = await signer.claim({
+      igp_id: igpBefore.id,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpAfter = igps.igps[igps.igps.length - 1];
+
+    expect(igpAfter.id).to.equal(igpBefore.id);
+    expect(igpAfter.denom).to.equal(igpBefore.denom);
+    expect(igpAfter.claimable_fees).to.be.empty;
+  });
+
+  step('set igp owner', async () => {
+    // ARRANGE
+    const newOwner = (await createSigner('bob')).account.address;
+
+    let igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpBefore = igps.igps[igps.igps.length - 1];
+    expect(igpBefore.owner).to.equal(signer.account.address);
+
+    // ACT
+    const txResponse = await signer.setIgpOwner({
+      igp_id: igpBefore.id,
+      new_owner: newOwner,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    igps = await signer.query.postDispatch.Igps({});
+    expect(igps.igps).to.have.lengthOf(2);
+
+    const igpAfter = igps.igps[igps.igps.length - 1];
+
+    expect(igpAfter.id).to.equal(igpBefore.id);
+    expect(igpAfter.owner).to.equal(newOwner);
+    expect(igpAfter.denom).to.equal(igpBefore.denom);
+  });
+});
diff --git a/typescript/cosmos-sdk/src/tests/4_warp.e2e-test.ts b/typescript/cosmos-sdk/src/tests/4_warp.e2e-test.ts
new file mode 100644
index 00000000000..aba04fa4579
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/4_warp.e2e-test.ts
@@ -0,0 +1,331 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import { HypTokenType } from '../../../cosmos-types/src/types/hyperlane/warp/v1/types.js';
+import {
+  addressToBytes32,
+  bytes32ToAddress,
+  convertToProtocolAddress,
+  isValidAddressEvm,
+} from '../../../utils/src/addresses.js';
+import { formatMessage } from '../../../utils/src/messages.js';
+import { ProtocolType } from '../../../utils/src/types.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('4. cosmos sdk warp e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new collateral token', async () => {
+    // ARRANGE
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(0);
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailbox = mailboxes.mailboxes[0];
+    const denom = 'uhyp';
+
+    // ACT
+    const txResponse = await signer.createCollateralToken({
+      origin_mailbox: mailbox.id,
+      origin_denom: denom,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const token = txResponse.response;
+
+    expect(token.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(token.id))).to.be.true;
+
+    tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(1);
+
+    let tokenQuery = await signer.query.warp.Token({
+      id: token.id,
+    });
+
+    expect(tokenQuery.token).not.to.be.undefined;
+    expect(tokenQuery.token?.owner).to.equal(signer.account.address);
+    expect(tokenQuery.token?.origin_mailbox).to.equal(mailbox.id);
+    expect(tokenQuery.token?.origin_denom).to.equal(denom);
+    expect(tokenQuery.token?.ism_id).to.be.empty;
+    expect(tokenQuery.token?.token_type).to.equal(
+      HypTokenType.HYP_TOKEN_TYPE_COLLATERAL,
+    );
+  });
+
+  step('create new synthetic token', async () => {
+    // ARRANGE
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(1);
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailbox = mailboxes.mailboxes[0];
+
+    // ACT
+    const txResponse = await signer.createSyntheticToken({
+      origin_mailbox: mailbox.id,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const token = txResponse.response;
+
+    expect(token.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(token.id))).to.be.true;
+
+    tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    let tokenQuery = await signer.query.warp.Token({
+      id: token.id,
+    });
+
+    expect(tokenQuery.token).not.to.be.undefined;
+    expect(tokenQuery.token?.owner).to.equal(signer.account.address);
+    expect(tokenQuery.token?.origin_mailbox).to.equal(mailbox.id);
+    expect(tokenQuery.token?.origin_denom).to.equal(`hyperlane/${token.id}`);
+    expect(tokenQuery.token?.ism_id).to.be.empty;
+    expect(tokenQuery.token?.token_type).to.equal(
+      HypTokenType.HYP_TOKEN_TYPE_SYNTHETIC,
+    );
+  });
+
+  step('enroll remote router', async () => {
+    // ARRANGE
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const token = tokens.tokens[0];
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailbox = mailboxes.mailboxes[0];
+
+    let remoteRouters = await signer.query.warp.RemoteRouters({
+      id: token.id,
+    });
+    expect(remoteRouters.remote_routers).to.have.lengthOf(0);
+    const gas = '10000';
+
+    // ACT
+    const txResponse = await signer.enrollRemoteRouter({
+      token_id: token.id,
+      remote_router: {
+        receiver_domain: mailbox.local_domain,
+        receiver_contract: mailbox.id,
+        gas,
+      },
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    remoteRouters = await signer.query.warp.RemoteRouters({
+      id: token.id,
+    });
+    expect(remoteRouters.remote_routers).to.have.lengthOf(1);
+
+    const remoteRouter = remoteRouters.remote_routers[0];
+
+    expect(remoteRouter.receiver_domain).to.equal(mailbox.local_domain);
+    expect(remoteRouter.receiver_contract).to.equal(mailbox.id);
+    expect(remoteRouter.gas).to.equal(gas);
+  });
+
+  step('remote transfer', async () => {
+    // ARRANGE
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const token = tokens.tokens[0];
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    let mailbox = mailboxes.mailboxes[0];
+    expect(mailbox.message_sent).to.equal(0);
+
+    const isms = await signer.query.interchainSecurity.DecodedIsms({});
+    const igps = await signer.query.postDispatch.Igps({});
+    const merkleTreeHooks = await signer.query.postDispatch.MerkleTreeHooks({});
+
+    const mailboxTxResponse = await signer.setMailbox({
+      mailbox_id: mailbox.id,
+      default_ism: isms.isms[0].id,
+      default_hook: igps.igps[0].id,
+      required_hook: merkleTreeHooks.merkle_tree_hooks[0].id,
+      new_owner: '',
+    });
+    expect(mailboxTxResponse.code).to.equal(0);
+
+    let remoteRouters = await signer.query.warp.RemoteRouters({
+      id: token.id,
+    });
+    expect(remoteRouters.remote_routers).to.have.lengthOf(1);
+
+    const remoteRouter = remoteRouters.remote_routers[0];
+
+    const interchainGas = await signer.query.warp.QuoteRemoteTransfer({
+      id: token.id,
+      destination_domain: remoteRouter.receiver_domain.toString(),
+    });
+
+    // ACT
+    const txResponse = await signer.remoteTransfer({
+      token_id: token.id,
+      destination_domain: remoteRouter.receiver_domain,
+      recipient: addressToBytes32(
+        convertToProtocolAddress(signer.account.address, ProtocolType.Ethereum),
+        ProtocolType.Ethereum,
+      ),
+      amount: '1000000',
+      custom_hook_id: '',
+      gas_limit: remoteRouter.gas,
+      max_fee: interchainGas.gas_payment[0],
+      custom_hook_metadata: '',
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const messageId = txResponse.response.message_id;
+    expect(isValidAddressEvm(bytes32ToAddress(messageId))).to.be.true;
+
+    mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    mailbox = mailboxes.mailboxes[0];
+    expect(mailbox.message_sent).to.equal(1);
+  });
+
+  step('process message', async () => {
+    // ARRANGE
+    const domainId = 1234;
+    const gas = '10000';
+
+    let mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailboxBefore = mailboxes.mailboxes[0];
+    expect(mailboxBefore.message_received).to.equal(0);
+
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const token = tokens.tokens[1];
+
+    const routerTxResponse = await signer.enrollRemoteRouter({
+      token_id: token.id,
+      remote_router: {
+        receiver_domain: mailboxBefore.local_domain,
+        receiver_contract: mailboxBefore.id,
+        gas,
+      },
+    });
+
+    expect(routerTxResponse.code).to.equal(0);
+
+    const message = formatMessage(
+      3,
+      0,
+      domainId,
+      mailboxBefore.id,
+      mailboxBefore.local_domain,
+      token.id,
+      '0x0000000000000000000000000c60e7ecd06429052223c78452f791aab5c5cac60000000000000000000000000000000000000000000000000000000002faf080',
+    );
+
+    // ACT
+    const txResponse = await signer.processMessage({
+      mailbox_id: mailboxBefore.id,
+      metadata: '',
+      message,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    mailboxes = await signer.query.core.Mailboxes({});
+    expect(mailboxes.mailboxes).to.have.lengthOf(2);
+
+    const mailboxAfter = mailboxes.mailboxes[0];
+    expect(mailboxAfter.message_received).to.equal(1);
+  });
+
+  step('unroll remote router', async () => {
+    // ARRANGE
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const token = tokens.tokens[0];
+
+    let remoteRouters = await signer.query.warp.RemoteRouters({
+      id: token.id,
+    });
+    expect(remoteRouters.remote_routers).to.have.lengthOf(1);
+
+    const receiverDomainId = 1234;
+
+    // ACT
+    const txResponse = await signer.unrollRemoteRouter({
+      token_id: token.id,
+      receiver_domain: receiverDomainId,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    remoteRouters = await signer.query.warp.RemoteRouters({
+      id: token.id,
+    });
+    expect(remoteRouters.remote_routers).to.have.lengthOf(0);
+  });
+
+  step('set token', async () => {
+    // ARRANGE
+    const newOwner = (await createSigner('bob')).account.address;
+
+    let tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const tokenBefore = tokens.tokens[tokens.tokens.length - 1];
+
+    // ACT
+    const txResponse = await signer.setToken({
+      token_id: tokenBefore.id,
+      ism_id: '',
+      new_owner: newOwner,
+    });
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    tokens = await signer.query.warp.Tokens({});
+    expect(tokens.tokens).to.have.lengthOf(2);
+
+    const tokenAfter = tokens.tokens[tokens.tokens.length - 1];
+
+    expect(tokenAfter.id).to.equal(tokenBefore.id);
+    expect(tokenAfter.owner).to.equal(newOwner);
+    expect(tokenAfter.origin_mailbox).to.equal(tokenBefore.origin_mailbox);
+    expect(tokenAfter.origin_denom).to.equal(tokenBefore.origin_denom);
+    expect(tokenAfter.ism_id).to.equal(tokenBefore.ism_id);
+    expect(tokenAfter.token_type).to.equal(tokenBefore.token_type);
+  });
+});
diff --git a/typescript/cosmos-sdk/src/tests/index.e2e-test.ts b/typescript/cosmos-sdk/src/tests/index.e2e-test.ts
new file mode 100644
index 00000000000..b3cc13d21f4
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/index.e2e-test.ts
@@ -0,0 +1,5 @@
+// enforce order of test suites
+import './1_interchain_security.e2e-test.js';
+import './2_core.e2e-test.js';
+import './3_post_dispatch.e2e-test.js';
+import './4_warp.e2e-test.js';
diff --git a/typescript/cosmos-sdk/src/tests/utils.ts b/typescript/cosmos-sdk/src/tests/utils.ts
new file mode 100644
index 00000000000..597cb5dbc12
--- /dev/null
+++ b/typescript/cosmos-sdk/src/tests/utils.ts
@@ -0,0 +1,29 @@
+import { DirectSecp256k1Wallet } from '@cosmjs/proto-signing';
+import { GasPrice } from '@cosmjs/stargate';
+
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+// These private keys are public and contain funds on the Hyperlane Cosmos Simapp chain
+// which are only used for testing and contain no real funds.
+//
+// DO NOT USE THOSE KEYS IN PRODUCTION
+const PKS = {
+  alice: '33913dd43a5d5764f7a23da212a8664fc4f5eedc68db35f3eb4a5c4f046b5b51',
+  bob: '0afcf195989ebb6306f23271e50832332180b73055eb57f6d3c53263127e7d78',
+  charlie: '8ef41fc20bf963ce18494c0f13e9303f70abc4c1d1ecfdb0a329d7fd468865b8',
+};
+
+export const createSigner = async (account: 'alice' | 'bob' | 'charlie') => {
+  const wallet = await DirectSecp256k1Wallet.fromKey(
+    Buffer.from(PKS[account], 'hex'),
+    'hyp',
+  );
+
+  return SigningHyperlaneModuleClient.connectWithSigner(
+    'http://127.0.0.1:26657',
+    wallet,
+    {
+      gasPrice: GasPrice.fromString('0.2uhyp'),
+    },
+  );
+};
diff --git a/typescript/utils/src/validator.ts b/typescript/utils/src/validator.ts
index 63f135909d6..4876bc3869e 100644
--- a/typescript/utils/src/validator.ts
+++ b/typescript/utils/src/validator.ts
@@ -2,6 +2,7 @@ import { ethers } from 'ethers';
 
 import { eqAddress } from './addresses.js';
 import { domainHash } from './domains.js';
+import { fromHexString, toHexString } from './strings.js';
 import {
   Address,
   Checkpoint,
@@ -106,3 +107,34 @@ export class BaseValidator {
     throw new Error('Not implemented');
   }
 }
+
+/**
+ * Create signature for validator announce
+ */
+export const createAnnounce = async (
+  validatorPrivKey: string,
+  storageLocation: string,
+  mailboxId: string,
+  localDomain: number,
+) => {
+  const domainIdBytes = Buffer.alloc(4);
+  domainIdBytes.writeUInt32BE(localDomain);
+
+  const domainHashBytes = toHexString(
+    Buffer.concat([
+      domainIdBytes,
+      fromHexString(mailboxId),
+      Buffer.from('HYPERLANE_ANNOUNCEMENT'),
+    ]),
+  );
+  const domainHash = ethers.utils.keccak256(domainHashBytes);
+
+  const announcementDigestBytes = toHexString(
+    Buffer.concat([fromHexString(domainHash), Buffer.from(storageLocation)]),
+  );
+  const announcementDigest = ethers.utils.keccak256(announcementDigestBytes);
+
+  return new ethers.Wallet(validatorPrivKey).signMessage(
+    fromHexString(announcementDigest),
+  );
+};
diff --git a/yarn.lock b/yarn.lock
index 49bb339b268..9e5196e7875 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7745,12 +7745,15 @@ __metadata:
     "@cosmjs/stargate": "npm:^0.32.4"
     "@eslint/js": "npm:^9.15.0"
     "@hyperlane-xyz/cosmos-types": "npm:11.0.0"
+    "@types/mocha": "npm:^10.0.1"
     "@typescript-eslint/eslint-plugin": "npm:^8.1.6"
     "@typescript-eslint/parser": "npm:^8.1.6"
     eslint: "npm:^9.15.0"
     eslint-config-prettier: "npm:^9.1.0"
     eslint-import-resolver-typescript: "npm:^3.6.3"
     eslint-plugin-import: "npm:^2.31.0"
+    mocha: "npm:^10.2.0"
+    mocha-steps: "npm:^1.3.0"
     prettier: "npm:^2.8.8"
     typescript: "npm:5.3.3"
     typescript-eslint: "npm:^8.23.0"
@@ -30136,6 +30139,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mocha-steps@npm:^1.3.0":
+  version: 1.3.0
+  resolution: "mocha-steps@npm:1.3.0"
+  checksum: 10/ca36de467293b0c36290001cd0305df4a3e161fa52c20aff62fbca78566ec200610ff7b0f48eb720e76a3ffdbf91b04d478c27e15d31b1de7f0de787d63e774e
+  languageName: node
+  linkType: hard
+
 "mocha@npm:7.1.2":
   version: 7.1.2
   resolution: "mocha@npm:7.1.2"


Reviews:
user: login: paulbalaji
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-08T14:53:20Z
user: login: xeno097
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:58:43Z
user: login: xeno097
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:04:06Z
user: login: xeno097
body: Are there any not so happy paths that we can test? like incorrect arguments passed to the contracts or expected errors when miscondifguring something?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:09:59Z
user: login: troykessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:35:27Z
user: login: troykessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T15:36:34Z
user: login: xeno097
body: I'll approve but would love some one else to also have a look
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T17:04:31Z
user: login: paulbalaji
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-14T16:17:48Z

Review comments:
path: typescript/cosmos-sdk/scripts/run-e2e-test.sh
user: login: paulbalaji
body: you'll likely want to add a step, call it "cosmos-sdk-e2e", into the test.yml workflow https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/7bb83e8d0da461b41fa5aa5f4eebc612c8fb0a30/.github/workflows/test.yml

something like
```yaml
  cosmos-sdk-e2e-matrix:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha || github.sha }}
          submodules: recursive
          fetch-depth: 0

      - name: foundry-install
        uses: foundry-rs/foundry-toolchain@v1

      - name: yarn-build
        uses: ./.github/actions/yarn-build-with-cache
        with:
          ref: ${{ github.event.pull_request.head.sha || github.sha }}

      - name: Cosmos SDK e2e tests
        run: yarn --cwd typescript/cosmos-sdk test:e2e
```
created_at: 2025-04-08T14:53:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+function cleanup() {
+  docker compose down
+}
path: typescript/cosmos-sdk/scripts/run-e2e-test.sh
user: login: xeno097
body: This is not something that you need to do in this PR, but it might be worth exploring later. This allows to control docker containers from nodejs https://node.testcontainers.org/ 
created_at: 2025-04-09T14:58:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,166 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import {
+  MerkleRootMultisigISM,
+  MessageIdMultisigISM,
+} from '../../../cosmos-types/dist/types/hyperlane/core/interchain_security/v1/types.js';
+import {
+  bytes32ToAddress,
+  isValidAddressEvm,
+} from '../../../utils/dist/addresses.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('1. cosmos sdk interchain security e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new NOOP ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.be.empty;
+
+    // ACT
+    const txResponse = await signer.createNoopIsm({});
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const noopIsm = txResponse.response;
+
+    expect(noopIsm.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(noopIsm.id))).to.be.true;
+
+    isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    let ism = await signer.query.interchainSecurity.Ism({
+      id: noopIsm.id,
+    });
+    expect(ism.ism?.type_url).to.equal(
+      '/hyperlane.core.interchain_security.v1.NoopISM',
+    );
+
+    let decodedIsm = await signer.query.interchainSecurity.DecodedIsm({
+      id: noopIsm.id,
+    });
+    expect(decodedIsm.ism.id).to.equal(noopIsm.id);
+    expect(decodedIsm.ism.owner).to.equal(signer.account.address);
+  });
+
+  step('create new MessageIdMultisig ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    const threshold = 2;
+    const validators = [
+      '0x3C24F29fa75869A1C9D19d9d6589Aae0B5227c3c',
+      '0xf719b4CC64d0E3a380e52c2720Abab13835F6d9c',
+      '0x98A56EdE1d6Dd386216DA8217D9ac1d2EE7c27c7',
+    ];
+
+    // note that the validators need to be sorted alphabetically
path: typescript/cosmos-sdk/src/tests/1_interchain_security.e2e-test.ts
user: login: xeno097
body: What happens if they are not sorted? Shouldn't we test the case where they are not sorted too?
created_at: 2025-04-09T15:04:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+function cleanup() {
+  docker compose down
+}
path: typescript/cosmos-sdk/scripts/run-e2e-test.sh
user: login: troykessler
body: nice, will try implement it
created_at: 2025-04-09T15:35:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,166 @@
+import { expect } from 'chai';
+import { step } from 'mocha-steps';
+
+import {
+  MerkleRootMultisigISM,
+  MessageIdMultisigISM,
+} from '../../../cosmos-types/dist/types/hyperlane/core/interchain_security/v1/types.js';
+import {
+  bytes32ToAddress,
+  isValidAddressEvm,
+} from '../../../utils/dist/addresses.js';
+import { SigningHyperlaneModuleClient } from '../index.js';
+
+import { createSigner } from './utils.js';
+
+describe('1. cosmos sdk interchain security e2e tests', async function () {
+  this.timeout(100_000);
+
+  let signer: SigningHyperlaneModuleClient;
+
+  before(async () => {
+    signer = await createSigner('alice');
+  });
+
+  step('create new NOOP ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.be.empty;
+
+    // ACT
+    const txResponse = await signer.createNoopIsm({});
+
+    // ASSERT
+    expect(txResponse.code).to.equal(0);
+
+    const noopIsm = txResponse.response;
+
+    expect(noopIsm.id).to.be.not.empty;
+    expect(isValidAddressEvm(bytes32ToAddress(noopIsm.id))).to.be.true;
+
+    isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    let ism = await signer.query.interchainSecurity.Ism({
+      id: noopIsm.id,
+    });
+    expect(ism.ism?.type_url).to.equal(
+      '/hyperlane.core.interchain_security.v1.NoopISM',
+    );
+
+    let decodedIsm = await signer.query.interchainSecurity.DecodedIsm({
+      id: noopIsm.id,
+    });
+    expect(decodedIsm.ism.id).to.equal(noopIsm.id);
+    expect(decodedIsm.ism.owner).to.equal(signer.account.address);
+  });
+
+  step('create new MessageIdMultisig ISM', async () => {
+    // ARRANGE
+    let isms = await signer.query.interchainSecurity.Isms({});
+    expect(isms.isms).to.have.lengthOf(1);
+
+    const threshold = 2;
+    const validators = [
+      '0x3C24F29fa75869A1C9D19d9d6589Aae0B5227c3c',
+      '0xf719b4CC64d0E3a380e52c2720Abab13835F6d9c',
+      '0x98A56EdE1d6Dd386216DA8217D9ac1d2EE7c27c7',
+    ];
+
+    // note that the validators need to be sorted alphabetically
path: typescript/cosmos-sdk/src/tests/1_interchain_security.e2e-test.ts
user: login: troykessler
body: this is a requirement from the chain module side, if we would submit them not sorted the tx would fail. Testing such a thing would be more on the chain side instead of the TS sdk side
created_at: 2025-04-09T15:36:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-04-08T14:43:18Z
body: ###  ⚠️  No Changeset found

Latest commit: e2bd31d54862321c6b974bd1bafc3c44748962eb

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/hyperlane-xyz/hyperlane-monorepo/new/troy/cosmos-sdk-e2e-tests?filename=.changeset/unlucky-panthers-vanish.md&value=---%0A%22%40hyperlane-xyz%2Fcosmos-sdk%22%3A%20patch%0A%22%40hyperlane-xyz%2Futils%22%3A%20patch%0A---%0A%0Atest%3A%20add%20cosmos-sdk%20e2e%20tests%0A)


author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-08T14:55:29Z
body: ## [Codecov](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 78.26%. Comparing base [(`7bb83e8`)](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/commit/7bb83e8d0da461b41fa5aa5f4eebc612c8fb0a30?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) to head [(`e2bd31d`)](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/commit/e2bd31d54862321c6b974bd1bafc3c44748962eb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz).
> Report is 33 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #5862   +/-   ##
=======================================
  Coverage   78.26%   78.26%           
=======================================
  Files         109      109           
  Lines        2158     2158           
  Branches      195      195           
=======================================
  Hits         1689     1689           
  Misses        448      448           
  Partials       21       21           
```

| [Components](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | Coverage Δ | |
|---|---|---|
| [core](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | `87.80% <ø> (ø)` | |
| [hooks](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | `82.78% <ø> (ø)` | |
| [isms](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | `84.71% <ø> (ø)` | |
| [token](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | `90.54% <ø> (ø)` | |
| [middlewares](https://app.codecov.io/gh/hyperlane-xyz/hyperlane-monorepo/pull/5862/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hyperlane-xyz) | `79.80% <ø> (ø)` | |

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: troykessler
created_at: 2025-04-09T15:39:43Z
body: > Are there any not so happy paths that we can test? like incorrect arguments passed to the contracts or expected errors when miscondifguring something?

So my idea for the cosmos-sdk e2e tests were that I expect that the cosmos chain module works and I only test if the TypeScript SDK is able to submit the txs without issues to the chain. Can still add a few misconfigurations and see if they are handled correctly, but would not go any further since any logic testing would be expected on the chain side in my opinion
author_association: CONTRIBUTOR
user: login: xeno097
created_at: 2025-04-09T17:03:54Z
body: > > Are there any not so happy paths that we can test? like incorrect arguments passed to the contracts or expected errors when miscondifguring something?
> 
> So my idea for the cosmos-sdk e2e tests were that I expect that the cosmos chain module works and I only test if the TypeScript SDK is able to submit the txs without issues to the chain. Can still add a few misconfigurations and see if they are handled correctly, but would not go any further since any logic testing would be expected on the chain side in my opinion

fair enough
author_association: CONTRIBUTOR