From 1c9a79263df60d8452c96d8ab51eb3458c77210a Mon Sep 17 00:00:00 2001 From: chatton Date: Thu, 27 Nov 2025 10:50:28 +0000 Subject: [PATCH 1/4] chore: adding python script to execute hermes commands --- scripts/update_ibc_clients_backfill.py | 208 +++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100755 scripts/update_ibc_clients_backfill.py diff --git a/scripts/update_ibc_clients_backfill.py b/scripts/update_ibc_clients_backfill.py new file mode 100755 index 00000000..d18964ed --- /dev/null +++ b/scripts/update_ibc_clients_backfill.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +import argparse +import json +import subprocess +import sys +import logging +from typing import Dict, List, Tuple, Set + +logger = logging.getLogger("update_ibc_clients") + + +def _log(level: int, msg: str) -> None: + logger.log(level, msg) + +def _run_hermes(args: List[str], timeout: float = 60.0) -> Dict: + cmd = ["hermes", "--json"] + args + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if res.returncode != 0: + err = (res.stderr or res.stdout or "").strip() + raise subprocess.CalledProcessError(res.returncode, cmd, output=res.stdout, stderr=err) + try: + return json.loads(res.stdout) + except json.JSONDecodeError as e: + raise RuntimeError( + f"failed to decode hermes JSON for {cmd}: {e}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}" +) + +def _query_channels(chain: str) -> List[dict]: + data = _run_hermes(["query", "channels", "--chain", chain], timeout=90.0) + result = data.get("result", []) + return result if isinstance(result, list) else [] + +def _query_connection_end(chain: str, connection: str) -> Dict: + return _run_hermes(["query", "connection", "end", "--chain", chain, "--connection", connection]) + +def _extract_client_ids(conn_end: Dict) -> Tuple[str, str]: + # Returns (client_id_on_A, client_id_on_B) (B is counterparty's client on host chain) + conn = conn_end.get("result", {}).get("connection", {}) + end = conn.get("end", {}) + client_a = end.get("client_id") or conn.get("client_id") or "" + counterparty = end.get("counterparty") or conn.get("counterparty") or {} + client_b = counterparty.get("client_id") or "" + return client_a, client_b + +def _query_client_state(chain_id: str, client_id: str) -> Dict: + return _run_hermes(["query", "client", "state", "--chain", chain_id, "--client", client_id]) + +def _extract_chain_id(client_state: Dict) -> str: + # Hermes variants: client_state or ClientState + result = client_state.get("result") or [] + if not isinstance(result, list) or not result: + return "" + entry = result[0] + cs = entry.get("client_state") or entry.get("ClientState") or {} + return cs.get("chain_id") or "" + +def _extract_revision_height(client_state: Dict) -> int: + # Parse latest_height.revision_height as int + result = client_state.get("result", []) + if not isinstance(result, list) or not result: + return 0 + entry = result[0] + client_state = entry.get("client_state") or entry.get("ClientState") or {} + latest_height = client_state.get("latest_height") or {} + revision_height = latest_height.get("revision_height") + if isinstance(revision_height, int): + return revision_height + if isinstance(revision_height, str) and revision_height.isdigit(): + return int(revision_height) + try: + return int(str(revision_height)) + except Exception: + _log(logging.WARN, f"failed to parse latest_height.revision_height: {revision_height}") + return 0 + +def _update_client(host_chain: str, client_id: str, height: int = None) -> None: + args = ["update", "client", "--host-chain", host_chain, "--client", client_id] + if height is not None: + args += ["--height", str(height)] + _run_hermes(args, timeout=120.0) + +def _discover_host_clients(chains: List[str]) -> List[Tuple[str, str, str]]: + """ + Returns unique triples: (host_chain, client_id_on_host, subject_chain_id). + host_chain = where the client lives (counterparty), subject_chain_id = chain tracked by client. + """ + seen: Set[Tuple[str, str]] = set() + triples: List[Tuple[str, str, str]] = [] + + for chain in chains: + _log(logging.INFO, f"scanning channels on {chain}") + try: + channels = _query_channels(chain) + except Exception as e: + _log(logging.WARN, f"failed to query channels on {chain}: {e}") + continue + + for idx, channel in enumerate(channels): + hops = channel.get("connection_hops") or [] + port = channel.get("port_id") or "" + chan = channel.get("channel_id") or "" + if not hops or not port or not chan: + _log(logging.WARN, f"{chain} channel[{idx}] missing connection/port/channel, skipping") + continue + + conn_a = hops[0] + _log(logging.INFO, f"{chain} {port}/{chan} via {conn_a}") + + try: + conn_end = _query_connection_end(chain, conn_a) + except Exception as e: + _log(logging.WARN, f"failed to query connection end {conn_a} on {chain}: {e}") + continue + + client_id_a, client_id_b = _extract_client_ids(conn_end) + if not client_id_a or not client_id_b: + _log(logging.WARN, f"missing client IDs for {chain}/{conn_a}; skipping") + continue + + try: + client_state = _query_client_state(chain, client_id_a) + except Exception as e: + _log(logging.WARN, f"failed to query client state {client_id_a} on {chain}: {e}") + continue + + subject_chain = _extract_chain_id(client_state) + if not subject_chain: + _log(logging.WARN, f"could not determine counterparty chain-id for {chain}/{conn_a} (client {client_id_a}); skipping") + continue + + host_chain = subject_chain # update on the counterparty + key = (host_chain, client_id_b) + if key in seen: + continue + seen.add(key) + triples.append((host_chain, client_id_b, subject_chain)) + _log(logging.INFO, f"discovered host={host_chain} client={client_id_b} (tracks {subject_chain})") + + return triples + +def _backfill_client(host_chain: str, client_id: str, end_height: int) -> None: + # Query trusted height on the host client + try: + client_state = _query_client_state(host_chain, client_id) + except Exception as e: + _log(logging.ERROR, f"query client state failed on host={host_chain} client={client_id}: {e}") + return + + trusted_height = _extract_revision_height(client_state) + if trusted_height <= 0: + _log(logging.WARN, f"could not parse trusted height (got {trusted_height}) on host={host_chain} client={client_id}") + + start = trusted_height + 1 + if start > end_height: + _log(logging.INFO, f"host={host_chain} client={client_id}: already >= end ({trusted_height} >= {end_height}). skipping") + return + + _log(logging.INFO, f"host={host_chain} client={client_id}: backfill {start}..{end_height}") + failures = 0 + for h in range(start, end_height + 1): + try: + _update_client(host_chain, client_id, height=h) + except subprocess.CalledProcessError as e: + failures += 1 + _log(logging.ERROR, f"update --height {h} failed for host={host_chain} client={client_id}: {e.stderr or e.output}") + continue + except Exception as e: + failures += 1 + _log(logging.ERROR, f"update --height {h} failed for host={host_chain} client={client_id}: {e}") + continue + + if failures == 0: + _log(logging.INFO, f"host={host_chain} client={client_id}: backfill complete") + else: + _log(logging.WARN, f"host={host_chain} client={client_id}: backfill complete with {failures} failures") + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + ap = argparse.ArgumentParser( + description="Backfill IBC UpdateClient from trusted+1 to migrationHeight+window for all connected channels' counterparties using Hermes." + ) + ap.add_argument("chains", nargs="+", help="Chain IDs to scan (e.g., gm-1 gm-2)") + ap.add_argument("--migration-height", type=int, required=True, help="Block height where smoothing starts") + ap.add_argument("--window", type=int, default=30, help="Smoothing window length (default: 30)") + args = ap.parse_args() + + end_height = args.migration_height + args.window + if end_height <= 0: + _log(logging.ERROR, "end height must be positive") + sys.exit(2) + + triples = _discover_host_clients(args.chains) + if not triples: + _log(logging.WARN, "no host clients discovered; nothing to do") + return + + for host_chain, client_id, _subject_chain in triples: + _backfill_client(host_chain, client_id, end_height) + + _log(logging.INFO, "backfill attempted for all discovered host clients") + +if __name__ == "__main__": + main() From 985b7d030e670d7ab7c8c2eae33109dce0b401dc Mon Sep 17 00:00:00 2001 From: chatton Date: Thu, 27 Nov 2025 14:00:42 +0000 Subject: [PATCH 2/4] wip: adding setup scripts to test scenario --- scripts/e2e/Makefile | 95 +++++++++ scripts/e2e/README.md | 42 ++++ scripts/e2e/chain-entry.sh | 64 ++++++ scripts/e2e/docker-compose.yml | 125 +++++++++++ scripts/e2e/hermes-entry.sh | 107 ++++++++++ scripts/e2e/hermes-init.sh | 77 +++++++ scripts/e2e/ibc-setup.sh | 29 +++ scripts/e2e/init-chain.sh | 73 +++++++ scripts/e2e/migrate-stayoncomet.sh | 106 ++++++++++ scripts/e2e/relayer.mnemonic | 1 + scripts/e2e/update_ibc_clients_backfill.py | 234 +++++++++++++++++++++ 11 files changed, 953 insertions(+) create mode 100644 scripts/e2e/Makefile create mode 100644 scripts/e2e/README.md create mode 100644 scripts/e2e/chain-entry.sh create mode 100644 scripts/e2e/docker-compose.yml create mode 100644 scripts/e2e/hermes-entry.sh create mode 100644 scripts/e2e/hermes-init.sh create mode 100644 scripts/e2e/ibc-setup.sh create mode 100644 scripts/e2e/init-chain.sh create mode 100755 scripts/e2e/migrate-stayoncomet.sh create mode 100644 scripts/e2e/relayer.mnemonic create mode 100644 scripts/e2e/update_ibc_clients_backfill.py diff --git a/scripts/e2e/Makefile b/scripts/e2e/Makefile new file mode 100644 index 00000000..5653004f --- /dev/null +++ b/scripts/e2e/Makefile @@ -0,0 +1,95 @@ +SHELL := /bin/bash + +COMPOSE := docker compose -f docker-compose.yml + +# Default ports exposed in compose +RPC_A ?= 26657 +GRPC_A ?= 9090 +RPC_B ?= 26657 +GRPC_B ?= 9090 +RPC_C ?= 26657 +GRPC_C ?= 9090 + +# Migration window and height (set MIG when running backfill) +WINDOW ?= 30 +MIG ?= + +.PHONY: help +help: + @echo "E2E orchestration targets:" + @echo " make up - start chains (wait for height >= 1)" + @echo " make down - stop containers" + @echo " make ps - list containers" + @echo " make logs-a/b/c - tail chain logs" + @echo " make hermes-start - start Hermes only (after chains ready)" + @echo " make ibc-setup - fund relayer and create A<->B, A<->C channels" + @echo " make migrate - submit StayOnComet migration on A (WINDOW=$(WINDOW))" + @echo " make backfill MIG= [WINDOW=$(WINDOW)] - run client backfill" + @echo " make clean - down and remove volumes" + +.PHONY: up +up: + $(COMPOSE) up -d gm-a-val-0 gm-a-val-1 gm-a-val-2 gm-b-val-0 gm-c-val-0 hermes + @echo "[wait] waiting for chains to reach height >= 1" + @bash -c 'set -e; \ + parse_height() { \ + url="$$1"; \ + if command -v jq >/dev/null 2>&1; then \ + curl -s "$$url" | jq -r .result.sync_info.latest_block_height 2>/dev/null || true; \ + else \ + curl -s "$$url" | sed -n "s/.*\"latest_block_height\":\"\([0-9]\+\)\".*/\1/p"; \ + fi; \ + }; \ + endpoints=("http://localhost:26657/status" "http://localhost:27657/status" "http://localhost:28657/status"); \ + for ep in "$${endpoints[@]}"; do \ + echo " waiting on $$ep"; \ + ok=0; \ + for i in $$(seq 1 120); do \ + h=$$(parse_height $$ep); \ + if [ -n "$$h" ] && [ "$$h" -ge 1 ] 2>/dev/null; then \ + echo " ok height=$$h"; ok=1; break; \ + fi; \ + sleep 1; \ + done; \ + if [ $$ok -ne 1 ]; then echo " timeout waiting for $$ep" >&2; exit 1; fi; \ + done' + @echo "[up] Hermes started (gated by chain healthchecks)" + +.PHONY: down +down: + $(COMPOSE) down + +.PHONY: ps +ps: + $(COMPOSE) ps + +.PHONY: logs-a logs-b logs-c +logs-a: + docker logs -f gm-a-val-0 +logs-b: + docker logs -f gm-b-val-0 +logs-c: + docker logs -f gm-c-val-0 + +.PHONY: init + +.PHONY: hermes-start +hermes-start: + $(COMPOSE) up -d hermes + +.PHONY: ibc-setup +ibc-setup: + bash ./ibc-setup.sh + +.PHONY: migrate +migrate: + WINDOW=$(WINDOW) bash ./migrate-stayoncomet.sh $(WINDOW) + +.PHONY: backfill +backfill: + @if [ -z "$(MIG)" ]; then echo "Set MIG= (e.g., MIG=120)" >&2; exit 2; fi + python3 ./update_ibc_clients_backfill.py gm-a gm-b gm-c --migration-height $(MIG) --window $(WINDOW) + +.PHONY: clean +clean: + $(COMPOSE) down -v diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 00000000..7ba852e9 --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,42 @@ +Local 3-Chain + Hermes E2E (StayOnComet) + +What this provides +- 3 Cosmos-SDK chains: gm-a, gm-b, gm-c (single validator each by default) +- One Hermes relayer configured for all chains +- ICS20 channels between A<->B and A<->C +- A governance-driven migration on chain A with StayOnComet=true +- A Python backfill script to advance IBC clients across the smoothing window +- Simple transfer verification after the migration + +Quick start +1) Spin up containers + - From repo root: docker compose -f scripts/e2e/docker-compose.yml up -d + +2) Initialize chains (single validator each) and start nodes + - scripts/e2e/init-chain.sh gm-a-val-0 gm-a 26657 9090 + - scripts/e2e/init-chain.sh gm-b-val-0 gm-b 26657 9090 + - scripts/e2e/init-chain.sh gm-c-val-0 gm-c 26657 9090 + +3) Configure Hermes and add keys + - scripts/e2e/hermes-init.sh + +4) Fund Hermes relayer addresses and create IBC channels (A<->B, A<->C) + - scripts/e2e/ibc-setup.sh + +5) Submit StayOnComet migration proposal on A + - scripts/e2e/migrate-stayoncomet.sh 30 + - Wait until chain A reaches migration_height + window + +6) Backfill client updates across the window + - python3 scripts/e2e/update_ibc_clients_backfill.py gm-a gm-b gm-c --migration-height --window 30 + +7) Verify IBC with a test transfer (e.g., from gm-a to gm-b) + - Use gmd tx ibc-transfer transfer ... on gm-a and watch Hermes relay + +Notes +- The compose file exposes RPC+gRPC for all chains on host ports: + - gm-a: RPC 26657, gRPC 9090 + - gm-b: RPC 27657, gRPC 9190 + - gm-c: RPC 28657, gRPC 9290 +- This scaffold initializes one validator per chain by default. If you need gm-a with 3 validators, you can extend the initialization to produce a multi-validator genesis and distribute it to gm-a-val-1/2; or start gm-a-val-1/2 as additional nodes and use staking txs to promote them to validators. The StayOnComet overlap logic is compatible with single or multiple validators. + diff --git a/scripts/e2e/chain-entry.sh b/scripts/e2e/chain-entry.sh new file mode 100644 index 00000000..e7b8ece9 --- /dev/null +++ b/scripts/e2e/chain-entry.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -e + +CHAIN_ID="${CHAIN_ID:-gm-a}" +RPC_PORT="${RPC_PORT:-26657}" +GRPC_PORT="${GRPC_PORT:-9090}" +DENOM="stake" +HOME_DIR="/home/gm/.gm" +MNEMONIC_FILE="/scripts/relayer.mnemonic" + +echo "[chain] init $CHAIN_ID (rpc=$RPC_PORT grpc=$GRPC_PORT)" + +mkdir -p "$HOME_DIR" "$HOME_DIR/config" + +if [ ! -f "$HOME_DIR/config/genesis.json" ]; then + gmd init "$HOSTNAME" --chain-id "$CHAIN_ID" --home "$HOME_DIR" + + # faucet key (ensure it exists) +if ! gmd keys show faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then + gmd keys add faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1 +fi + FAUCET_ADDR=$(gmd keys show faucet -a --keyring-backend test --home "$HOME_DIR") + + # add faucet genesis account by explicit address + if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis add-genesis-account "$FAUCET_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" + else + gmd add-genesis-account "$FAUCET_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" + fi + + # pre-fund relayer account from shared mnemonic (same address on all chains as Hermes) + if [ -f "$MNEMONIC_FILE" ]; then + if ! gmd keys show relayer --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then + # recover relayer key non-interactively from mnemonic file + gmd keys add relayer --recover --keyring-backend test --home "$HOME_DIR" < "$MNEMONIC_FILE" >/dev/null 2>&1 + fi + RELAYER_ADDR=$(gmd keys show relayer -a --keyring-backend test --home "$HOME_DIR" 2>/dev/null || true) + if [ -n "$RELAYER_ADDR" ]; then + if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis add-genesis-account "$RELAYER_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" + else + gmd add-genesis-account "$RELAYER_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" + fi + fi + fi + + # gentx + collect + if gmd genesis gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis gentx faucet 10000000000"$DENOM" --moniker "$HOSTNAME" --chain-id "$CHAIN_ID" --keyring-backend test --home "$HOME_DIR" + gmd genesis collect-gentxs --home "$HOME_DIR" + else + gmd gentx faucet 10000000000"$DENOM" --moniker "$HOSTNAME" --chain-id "$CHAIN_ID" --keyring-backend test --home "$HOME_DIR" + gmd collect-gentxs --home "$HOME_DIR" + fi + + # config + sed -i "s/^minimum-gas-prices.*/minimum-gas-prices = \"0.0$DENOM\"/" "$HOME_DIR/config/app.toml" + sed -i "s/^indexer.*/indexer = \"kv\"/" "$HOME_DIR/config/config.toml" + sed -i "s|127.0.0.1:26657|0.0.0.0:26657|" "$HOME_DIR/config/config.toml" + sed -i "s|127.0.0.1:9090|0.0.0.0:9090|" "$HOME_DIR/config/app.toml" +fi + +echo "[chain] starting $CHAIN_ID" +exec gmd start --home "$HOME_DIR" --rpc.laddr tcp://0.0.0.0:"$RPC_PORT" --grpc.address 0.0.0.0:"$GRPC_PORT" diff --git a/scripts/e2e/docker-compose.yml b/scripts/e2e/docker-compose.yml new file mode 100644 index 00000000..b3e584f1 --- /dev/null +++ b/scripts/e2e/docker-compose.yml @@ -0,0 +1,125 @@ +version: "3.8" + +services: + gm-a-val-0: + image: cosmos-gm:test + container_name: gm-a-val-0 + entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] + volumes: + - gm_a_val0:/home/gm/.gm + - ./:/scripts:ro + networks: [gmnet] + user: "0:0" + ports: + - "26657:26657" # RPC + - "9090:9090" # gRPC + environment: + - CHAIN_ID=gm-a + - RPC_PORT=26657 + - GRPC_PORT=9090 + healthcheck: + test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] + interval: 2s + timeout: 2s + retries: 60 + + gm-a-val-1: + image: cosmos-gm:test + container_name: gm-a-val-1 + entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] + volumes: + - gm_a_val1:/home/gm/.gm + - ./:/scripts:ro + networks: [gmnet] + user: "0:0" + environment: + - CHAIN_ID=gm-a + - RPC_PORT=26657 + - GRPC_PORT=9090 + + gm-a-val-2: + image: cosmos-gm:test + container_name: gm-a-val-2 + entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] + volumes: + - gm_a_val2:/home/gm/.gm + - ./:/scripts:ro + networks: [gmnet] + user: "0:0" + environment: + - CHAIN_ID=gm-a + - RPC_PORT=26657 + - GRPC_PORT=9090 + + gm-b-val-0: + image: cosmos-gm:test + container_name: gm-b-val-0 + entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] + volumes: + - gm_b_val0:/home/gm/.gm + - ./:/scripts:ro + networks: [gmnet] + user: "0:0" + ports: + - "27657:26657" + - "9190:9090" + environment: + - CHAIN_ID=gm-b + - RPC_PORT=26657 + - GRPC_PORT=9090 + healthcheck: + test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] + interval: 2s + timeout: 2s + retries: 60 + + gm-c-val-0: + image: cosmos-gm:test + container_name: gm-c-val-0 + entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] + volumes: + - gm_c_val0:/home/gm/.gm + - ./:/scripts:ro + networks: [gmnet] + user: "0:0" + ports: + - "28657:26657" + - "9290:9090" + environment: + - CHAIN_ID=gm-c + - RPC_PORT=26657 + - GRPC_PORT=9090 + healthcheck: + test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] + interval: 2s + timeout: 2s + retries: 60 + + hermes: + image: ghcr.io/informalsystems/hermes:1.13.1 + container_name: hermes + entrypoint: ["/bin/sh", "/scripts/hermes-entry.sh"] + volumes: + - hermes_home:/home/hermes/.hermes + - ./:/scripts:ro + networks: [gmnet] + depends_on: + gm-a-val-0: + condition: service_healthy + gm-b-val-0: + condition: service_healthy + gm-c-val-0: + condition: service_healthy + restart: unless-stopped + user: "0:0" + +networks: + gmnet: {} + +volumes: + gm_a_val0: {} + gm_a_val1: {} + gm_a_val2: {} + gm_b_val0: {} + gm_c_val0: {} + hermes_home: {} diff --git a/scripts/e2e/hermes-entry.sh b/scripts/e2e/hermes-entry.sh new file mode 100644 index 00000000..8f9186c7 --- /dev/null +++ b/scripts/e2e/hermes-entry.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -e + +CONFIG=/home/hermes/.hermes/config.toml +MNEMONIC=/scripts/relayer.mnemonic + +# Ensure config dir exists and is writable +mkdir -p /home/hermes/.hermes || true + +# Point Hermes to the generated config for all subsequent CLI calls +export HERMES_HOME=/home/hermes/.hermes +export HERMES_CONFIG="$CONFIG" + +cat > "$CONFIG" </dev/null | grep -q "$name"; then + echo " [$ch] key $name already present" + continue + fi + echo " [$ch] importing $name from mnemonic" + hermes --config "$CONFIG" keys add --chain "$ch" --mnemonic-file "$MNEMONIC" --key-name "$name" +done + +# Create connections + channels (idempotent) +echo "[hermes] creating IBC connections/channels" +# A <-> B +hermes --config "$CONFIG" create connection --a-chain gm-a --b-chain gm-b || true +hermes --config "$CONFIG" create channel \ + --a-chain gm-a --b-chain gm-b \ + --a-port transfer --b-port transfer \ + --order unordered --channel-version ics20-1 \ + --new-client-connection || true +# A <-> C +hermes --config "$CONFIG" create connection --a-chain gm-a --b-chain gm-c || true +hermes --config "$CONFIG" create channel \ + --a-chain gm-a --b-chain gm-c \ + --a-port transfer --b-port transfer \ + --order unordered --channel-version ics20-1 \ + --new-client-connection || true + +echo "[hermes] starting relayer" +exec hermes --config "$CONFIG" start diff --git a/scripts/e2e/hermes-init.sh b/scripts/e2e/hermes-init.sh new file mode 100644 index 00000000..4f5f8dd4 --- /dev/null +++ b/scripts/e2e/hermes-init.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERMES="docker exec -i hermes hermes" + +cat > /tmp/hermes-config.toml <<'EOF' +[global] +resolver = "gethostbyname" +log_level = "info" + +[mode] +clients = { enabled = true, refresh = true, misbehaviour = true } +connections = { enabled = true } +channels = { enabled = true } +packets = { enabled = true, clear_on_start = true } + +[rest] +enabled = true +host = "0.0.0.0" +port = 3000 + +[telemetry] +enabled = false + +[[chains]] +id = "gm-a" +rpc_addr = "http://gm-a-val-0:26657" +grpc_addr = "http://gm-a-val-0:9090" +event_source = { mode = "pull", interval = "1s" } +clock_drift = "60s" +trusted_node = false +account_prefix = "gm" +key_name = "relayer-gm-a" +store_prefix = "ibc" +default_gas = 250000 +max_gas = 400000 +gas_price = { price = 0.0, denom = "stake" } + +[[chains]] +id = "gm-b" +rpc_addr = "http://gm-b-val-0:26657" +grpc_addr = "http://gm-b-val-0:9090" +event_source = { mode = "pull", interval = "1s" } +clock_drift = "60s" +trusted_node = false +account_prefix = "gm" +key_name = "relayer-gm-b" +store_prefix = "ibc" +default_gas = 250000 +max_gas = 400000 +gas_price = { price = 0.0, denom = "stake" } + +[[chains]] +id = "gm-c" +rpc_addr = "http://gm-c-val-0:26657" +grpc_addr = "http://gm-c-val-0:9090" +event_source = { mode = "pull", interval = "1s" } +clock_drift = "60s" +trusted_node = false +account_prefix = "gm" +key_name = "relayer-gm-c" +store_prefix = "ibc" +default_gas = 250000 +max_gas = 400000 +gas_price = { price = 0.0, denom = "stake" } +EOF + +docker cp /tmp/hermes-config.toml hermes:/home/hermes/.hermes/config.toml + +# create keys and show addresses to fund +for ch in gm-a gm-b gm-c; do + $HERMES keys add --chain "$ch" --mnemonic-file /dev/null >/dev/null 2>&1 || true + $HERMES keys list --chain "$ch" +done + +echo "[ok] Hermes config written and keys added. Next: run scripts/e2e/ibc-setup.sh" + diff --git a/scripts/e2e/ibc-setup.sh b/scripts/e2e/ibc-setup.sh new file mode 100644 index 00000000..2ee64d4c --- /dev/null +++ b/scripts/e2e/ibc-setup.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERMES="docker exec -i hermes hermes --config /home/hermes/.hermes/config.toml" + +fund() { + local node="$1" chain_id="$2" key_name="$3" + local addr + addr=$($HERMES keys list --chain "$chain_id" | awk '/address/ {print $2; exit}' | tr -d '\r') + echo "[fund] $chain_id -> $addr" + docker exec -i "$node" sh -lc "gmd tx bank send faucet $addr 500000000stake --chain-id $chain_id --keyring-backend test --yes" +} + +# fund relayer addresses +fund gm-a-val-0 gm-a relayer-gm-a +fund gm-b-val-0 gm-b relayer-gm-b +fund gm-c-val-0 gm-c relayer-gm-c + +# give it a few blocks +sleep 3 + +# create connections + channels +$HERMES create connection --a-chain gm-a --b-chain gm-b +$HERMES create channel --a-chain gm-a --a-port transfer --b-port transfer --order unordered --channel-version ics20-1 + +$HERMES create connection --a-chain gm-a --b-chain gm-c +$HERMES create channel --a-chain gm-a --a-port transfer --b-port transfer --order unordered --channel-version ics20-1 + +echo "[ok] IBC connections and channels created between A<->B and A<->C" diff --git a/scripts/e2e/init-chain.sh b/scripts/e2e/init-chain.sh new file mode 100644 index 00000000..2d4d0ea7 --- /dev/null +++ b/scripts/e2e/init-chain.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Initialize and start a single-validator chain inside a container. +# Usage: ./init-chain.sh [rpc_port=26657] [grpc_port=9090] + +NODE="${1:-}" +CHAIN_ID="${2:-}" +RPC_PORT="${3:-26657}" +GRPC_PORT="${4:-9090}" + +if [[ -z "$NODE" || -z "$CHAIN_ID" ]]; then + echo "usage: $0 [rpc_port] [grpc_port]" >&2 + exit 2 +fi + +DENOM=stake + +echo "[init] $NODE ($CHAIN_ID)" +docker exec -u 0 -i "$NODE" sh -lc ' +set -e +HOME_DIR=/home/gm/.gm +mkdir -p "$HOME_DIR" +chmod -R 777 "$HOME_DIR" || true +if [ ! -d "$HOME_DIR/config" ]; then + gmd init '"$NODE"' --chain-id '"$CHAIN_ID"' --home "$HOME_DIR" +fi + +# faucet key +if ! gmd keys show faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then + gmd keys add faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1 +fi + +FAUCET_ADDR=$(gmd keys show faucet -a --keyring-backend test --home "$HOME_DIR") +# fund faucet (prefer new genesis subcommand, fallback to legacy top-level) +if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis add-genesis-account "$FAUCET_ADDR" 100000000000'"$DENOM"' --home "$HOME_DIR" +elif gmd add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd add-genesis-account "$FAUCET_ADDR" 100000000000'"$DENOM"' --home "$HOME_DIR" +fi + +# gentx if not present (handle SDK CLI variants) +if [ -z "$(ls -1 "$HOME_DIR"/config/gentx 2>/dev/null | head -n1)" ]; then + if gmd genesis gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis gentx faucet --amount 10000000000'"$DENOM"' --chain-id '"$CHAIN_ID"' --keyring-backend test --home "$HOME_DIR" + elif gmd gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd gentx faucet 10000000000'"$DENOM"' --chain-id '"$CHAIN_ID"' --keyring-backend test --home "$HOME_DIR" + fi +fi + +# collect gentxs (prefer new genesis subcommand) +if gmd genesis collect-gentxs --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd genesis collect-gentxs --home "$HOME_DIR" +elif gmd collect-gentxs --help --home "$HOME_DIR" >/dev/null 2>&1; then + gmd collect-gentxs --home "$HOME_DIR" +fi + +# app/config tweaks +sed -i "s/^minimum-gas-prices.*/minimum-gas-prices = \"0.0'"$DENOM"'\"/" "$HOME_DIR"/config/app.toml +sed -i "s/^indexer.*/indexer = \"kv\"/" "$HOME_DIR"/config/config.toml +sed -i "s|127.0.0.1:26657|0.0.0.0:26657|" "$HOME_DIR"/config/config.toml +sed -i "s|127.0.0.1:9090|0.0.0.0:9090|" "$HOME_DIR"/config/app.toml + +# reduce voting period for testing (30 seconds instead of 48 hours) +sed -i "s/\"voting_period\": \"172800s\"/\"voting_period\": \"30s\"/" "$HOME_DIR"/config/genesis.json +sed -i "s/\"expedited_voting_period\": \"86400s\"/\"expedited_voting_period\": \"15s\"/" "$HOME_DIR"/config/genesis.json +sed -i "s/\"max_deposit_period\": \"172800s\"/\"max_deposit_period\": \"30s\"/" "$HOME_DIR"/config/genesis.json + +# start +nohup gmd start --home "$HOME_DIR" --rpc.laddr tcp://0.0.0.0:'"$RPC_PORT"' --grpc.address 0.0.0.0:'"$GRPC_PORT"' \ + >/home/gm/node.log 2>&1 & +echo started +' diff --git a/scripts/e2e/migrate-stayoncomet.sh b/scripts/e2e/migrate-stayoncomet.sh new file mode 100755 index 00000000..d50a54ef --- /dev/null +++ b/scripts/e2e/migrate-stayoncomet.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +# migrate to single validator (validator-0) while staying on CometBFT +# hardcoded for gm-a-val-0 container + +NODE="gm-a-val-0" +CHAIN_ID="gm-a" +KEY="faucet" +HOME_DIR="/home/gm/.gm" +VALIDATORS=("gm-a-val-0" "gm-a-val-1" "gm-a-val-2") + +echo "[migrate-stayoncomet] getting current height..." +CURRENT_HEIGHT=$(docker exec "$NODE" sh -lc "gmd status --home $HOME_DIR" | grep -o '"latest_block_height":"[0-9]*"' | grep -o '[0-9]*') +MIGRATE_AT=$((CURRENT_HEIGHT + 30)) + +echo "[migrate-stayoncomet] current height: $CURRENT_HEIGHT, migration at: $MIGRATE_AT" + +echo "[migrate-stayoncomet] getting validator-0 pubkey..." +VALIDATOR_PUBKEY=$(docker exec "$NODE" sh -lc "gmd query staking validators --home $HOME_DIR --output json" | grep -A 2 '"consensus_pubkey"' | grep '"value"' | head -1 | sed 's/.*"value": "\(.*\)".*/\1/') + +echo "[migrate-stayoncomet] validator pubkey: $VALIDATOR_PUBKEY" + +echo "[migrate-stayoncomet] using hardcoded gov module address..." +GOV_ADDRESS="gm10d07y265gmmuvt4z0w9aw880jnsr700j5nsal6" + +echo "[migrate-stayoncomet] gov address: $GOV_ADDRESS" + +echo "[migrate-stayoncomet] creating proposal json..." +cat > /tmp/proposal.json < /tmp/submit_output.json 2>&1 + +SUBMIT_OUTPUT=$(cat /tmp/submit_output.json) +echo "[migrate-stayoncomet] submit output: $SUBMIT_OUTPUT" + +TXHASH=$(echo "$SUBMIT_OUTPUT" | grep -o '"txhash":"[^"]*"' | cut -d'"' -f4) +echo "[migrate-stayoncomet] tx hash: $TXHASH" + +echo "[migrate-stayoncomet] waiting for tx to be committed..." +sleep 5 + +echo "[migrate-stayoncomet] querying proposal ID from transaction..." +PROPOSAL_ID=$(docker exec "$NODE" sh -lc "gmd query tx $TXHASH --home $HOME_DIR --output json 2>/dev/null" | grep -o '"key":"proposal_id","value":"[0-9]*"' | grep -o '[0-9]*' | head -1) + +if [ -z "$PROPOSAL_ID" ]; then + echo "[migrate-stayoncomet] failed to extract proposal ID, querying all proposals..." + sleep 3 + PROPOSAL_ID=$(docker exec "$NODE" sh -lc "gmd query gov proposals --home $HOME_DIR --output json" | grep -o '\"id\":\"[0-9]*\"' | tail -1 | grep -o '[0-9]*') +fi + +echo "[migrate-stayoncomet] proposal ID: $PROPOSAL_ID" + +echo "[migrate-stayoncomet] waiting for proposal to be queryable..." +sleep 3 + +echo "[migrate-stayoncomet] voting yes on proposal from all validators..." +for val in "${VALIDATORS[@]}"; do + echo "[migrate-stayoncomet] voting from $val..." + docker exec "$val" sh -lc "gmd tx gov vote $PROPOSAL_ID yes \ + --from $KEY \ + --keyring-backend test \ + --chain-id $CHAIN_ID \ + --home $HOME_DIR \ + --yes" >/dev/null 2>&1 || echo "[migrate-stayoncomet] vote from $val may have failed" +done + +echo "[migrate-stayoncomet] waiting for votes to be processed..." +sleep 3 + +echo "[migrate-stayoncomet] proposal submitted and voted on" +echo "[migrate-stayoncomet] proposal ID: $PROPOSAL_ID" +echo "[migrate-stayoncomet] migration will execute at height $MIGRATE_AT" diff --git a/scripts/e2e/relayer.mnemonic b/scripts/e2e/relayer.mnemonic new file mode 100644 index 00000000..f1032c1a --- /dev/null +++ b/scripts/e2e/relayer.mnemonic @@ -0,0 +1 @@ +abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about diff --git a/scripts/e2e/update_ibc_clients_backfill.py b/scripts/e2e/update_ibc_clients_backfill.py new file mode 100644 index 00000000..fe52d9d9 --- /dev/null +++ b/scripts/e2e/update_ibc_clients_backfill.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +import sys +import time +from typing import List, Tuple, Set + + +def log(msg: str) -> None: + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + print(f"{ts} {msg}", file=sys.stderr) + + +def run_hermes(args: List[str], timeout: float = 60.0) -> dict: + cmd = ["hermes", "--json"] + args + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if res.returncode != 0: + err = (res.stderr or res.stdout or "").strip() + raise subprocess.CalledProcessError(res.returncode, cmd, output=res.stdout, stderr=err) + try: + return json.loads(res.stdout) + except json.JSONDecodeError as e: + raise RuntimeError( + f"failed to decode hermes JSON for {cmd}: {e}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}" + ) + + +def ensure_success(obj: dict, ctx: str) -> None: + st = obj.get("status") + if st and str(st).lower() != "success": + raise RuntimeError(f"{ctx}: status={st}") + + +def query_channels(chain: str) -> List[dict]: + data = run_hermes(["query", "channels", "--chain", chain], timeout=90.0) + ensure_success(data, f"query channels {chain}") + result = data.get("result", []) + return result if isinstance(result, list) else [] + + +def query_connection_end(chain: str, connection: str) -> dict: + data = run_hermes(["query", "connection", "end", "--chain", chain, "--connection", connection]) + ensure_success(data, f"query connection end {chain}/{connection}") + return data + + +def extract_client_ids(conn_end: dict) -> Tuple[str, str]: + # Returns (client_id_on_A, client_id_on_B) (B is counterparty's client on host chain) + root = conn_end.get("result") or {} + conn = root.get("connection") or {} + end = conn.get("end") or {} + client_a = end.get("client_id") or conn.get("client_id") or "" + cp = end.get("counterparty") or conn.get("counterparty") or {} + + if not client_a or not cp.get("client_id"): + # Fallback: connection_end + alt = root.get("connection_end") or {} + end2 = alt or {} + client_a = client_a or end2.get("client_id") or "" + cp = cp or end2.get("counterparty") or {} + + client_b = cp.get("client_id") or "" + return client_a, client_b + + +def query_client_state(chain: str, client_id: str) -> dict: + data = run_hermes(["query", "client", "state", "--chain", chain, "--client", client_id]) + ensure_success(data, f"query client state {chain}/{client_id}") + return data + + +def extract_chain_id(client_state: dict) -> str: + result = client_state.get("result") or [] + if not isinstance(result, list) or not result: + return "" + entry = result[0] + cs = entry.get("client_state") or entry.get("ClientState") or {} + return cs.get("chain_id") or "" + + +def extract_revision_height(client_state: dict) -> int: + result = client_state.get("result") or [] + if not isinstance(result, list) or not result: + return 0 + entry = result[0] + cs = entry.get("client_state") or entry.get("ClientState") or {} + lh = cs.get("latest_height") or {} + rh = lh.get("revision_height") + if isinstance(rh, int): + return rh + if isinstance(rh, str) and rh.isdigit(): + return int(rh) + try: + return int(str(rh)) + except Exception: + return 0 + + +def update_client(host_chain: str, client_id: str, height: int = None) -> None: + args = ["update", "client", "--host-chain", host_chain, "--client", client_id] + if height is not None: + args += ["--height", str(height)] + run_hermes(args, timeout=120.0) + + +def discover_host_clients(chains: List[str]) -> List[Tuple[str, str, str]]: + """ + Returns unique triples: (host_chain, client_id_on_host, subject_chain_id). + host_chain = where the client lives (counterparty), subject_chain_id = chain tracked by client. + """ + seen: Set[Tuple[str, str]] = set() + triples: List[Tuple[str, str, str]] = [] + + for chain in chains: + log(f"[info] scanning channels on {chain}") + try: + channels = query_channels(chain) + except Exception as e: + log(f"[warn] failed to query channels on {chain}: {e}") + continue + + for idx, ch in enumerate(channels): + hops = ch.get("connection_hops") or [] + port = ch.get("port_id") or "" + chan = ch.get("channel_id") or "" + if not hops or not port or not chan: + log(f"[warn] {chain} channel[{idx}] missing connection/port/channel; skipping") + continue + connA = hops[0] + log(f"[info] {chain} {port}/{chan} via {connA}") + + try: + conn_end = query_connection_end(chain, connA) + except Exception as e: + log(f"[warn] failed to query connection end {connA} on {chain}: {e}") + continue + + client_id_a, client_id_b = extract_client_ids(conn_end) + if not client_id_a or not client_id_b: + log(f"[warn] missing client IDs for {chain}/{connA}; skipping") + continue + + try: + cstate = query_client_state(chain, client_id_a) + except Exception as e: + log(f"[warn] failed to query client state {client_id_a} on {chain}: {e}") + continue + + subject_chain = extract_chain_id(cstate) + if not subject_chain: + log(f"[warn] could not determine counterparty chain-id for {chain}/{connA} (client {client_id_a}); skipping") + continue + + host_chain = subject_chain + key = (host_chain, client_id_b) + if key in seen: + continue + seen.add(key) + triples.append((host_chain, client_id_b, subject_chain)) + log(f"[info] discovered host={host_chain} client={client_id_b} (tracks {subject_chain})") + + return triples + + +def backfill_client(host_chain: str, client_id: str, end_height: int) -> None: + try: + cstate = query_client_state(host_chain, client_id) + except Exception as e: + log(f"[error] query client state failed on host={host_chain} client={client_id}: {e}") + return + + trusted = extract_revision_height(cstate) + if trusted <= 0: + log(f"[warn] could not parse trusted height (got {trusted}) on host={host_chain} client={client_id}") + + start = trusted + 1 + if start > end_height: + log(f"[info] host={host_chain} client={client_id}: already >= end ({trusted} >= {end_height}); skipping") + return + + log(f"[info] host={host_chain} client={client_id}: backfill {start}..{end_height}") + failures = 0 + for h in range(start, end_height + 1): + try: + update_client(host_chain, client_id, height=h) + except subprocess.CalledProcessError as e: + failures += 1 + log(f"[error] update --height {h} failed for host={host_chain} client={client_id}: {e.stderr or e.output}") + continue + except Exception as e: + failures += 1 + log(f"[error] update --height {h} failed for host={host_chain} client={client_id}: {e}") + continue + + if failures == 0: + log(f"[ok] host={host_chain} client={client_id}: backfill complete") + else: + log(f"[warn] host={host_chain} client={client_id}: backfill complete with {failures} failures") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Backfill IBC UpdateClient from trusted+1 to migrationHeight+window for all connected channels' counterparties using Hermes." + ) + ap.add_argument("chains", nargs="+", help="Chain IDs to scan (e.g., gm-a gm-b gm-c)") + ap.add_argument("--migration-height", type=int, required=True, help="Block height where smoothing starts") + ap.add_argument("--window", type=int, default=30, help="Smoothing window length (default: 30)") + ap.add_argument("--end-height", type=int, default=None, help="Override: backfill up to this height instead of migration_height+window") + args = ap.parse_args() + + end_height = args.end_height if args.end_height is not None else (args.migration_height + args.window) + if end_height <= 0: + log("[error] end height must be positive") + sys.exit(2) + + triples = discover_host_clients(args.chains) + if not triples: + log("[warn] no host clients discovered; nothing to do") + return + + for host_chain, client_id, _subject_chain in triples: + backfill_client(host_chain, client_id, end_height) + + log("[done] backfill attempted for all discovered host clients") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + log("[warn] interrupted") + sys.exit(130) + From f4908f8d321a5f72db251d241d03ef7984357017 Mon Sep 17 00:00:00 2001 From: chatton Date: Fri, 28 Nov 2025 08:58:06 +0000 Subject: [PATCH 3/4] chore: remove IBC specific logic, perform client update at H+1 --- modules/migrationmngr/depinject.go | 13 +- modules/migrationmngr/keeper/abci.go | 14 +- .../migrationmngr/keeper/grpc_query_test.go | 36 --- modules/migrationmngr/keeper/keeper.go | 42 +--- modules/migrationmngr/keeper/migration.go | 197 +-------------- scripts/e2e/Makefile | 95 ------- scripts/e2e/README.md | 42 ---- scripts/e2e/chain-entry.sh | 64 ----- scripts/e2e/docker-compose.yml | 125 ---------- scripts/e2e/hermes-entry.sh | 107 -------- scripts/e2e/hermes-init.sh | 77 ------ scripts/e2e/ibc-setup.sh | 29 --- scripts/e2e/init-chain.sh | 73 ------ scripts/e2e/migrate-stayoncomet.sh | 106 -------- scripts/e2e/relayer.mnemonic | 1 - scripts/e2e/update_ibc_clients_backfill.py | 234 ------------------ scripts/update_ibc_clients_backfill.py | 208 ---------------- .../single_validator_comet_migration_test.go | 69 ++---- 18 files changed, 24 insertions(+), 1508 deletions(-) delete mode 100644 scripts/e2e/Makefile delete mode 100644 scripts/e2e/README.md delete mode 100644 scripts/e2e/chain-entry.sh delete mode 100644 scripts/e2e/docker-compose.yml delete mode 100644 scripts/e2e/hermes-entry.sh delete mode 100644 scripts/e2e/hermes-init.sh delete mode 100644 scripts/e2e/ibc-setup.sh delete mode 100644 scripts/e2e/init-chain.sh delete mode 100755 scripts/e2e/migrate-stayoncomet.sh delete mode 100644 scripts/e2e/relayer.mnemonic delete mode 100644 scripts/e2e/update_ibc_clients_backfill.py delete mode 100755 scripts/update_ibc_clients_backfill.py diff --git a/modules/migrationmngr/depinject.go b/modules/migrationmngr/depinject.go index dde1f0fd..3c22d484 100644 --- a/modules/migrationmngr/depinject.go +++ b/modules/migrationmngr/depinject.go @@ -28,14 +28,10 @@ func init() { type ModuleInputs struct { depinject.In - Config *modulev1.Module - Cdc codec.Codec - StoreService store.KVStoreService - AddressCodec address.Codec - // optional, used to detect if IBC module is enabled. - // When IBC module is present, use `depinject.Provide(IBCStoreKey(ibcStoreKey))` - IBCStoreKey keeper.IbcKVStoreKeyAlias `optional:"true"` - + Config *modulev1.Module + Cdc codec.Codec + StoreService store.KVStoreService + AddressCodec address.Codec StakingKeeper types.StakingKeeper } @@ -59,7 +55,6 @@ func ProvideModule(in ModuleInputs) ModuleOutputs { in.StoreService, in.AddressCodec, in.StakingKeeper, - in.IBCStoreKey, authority.String(), ) m := NewAppModule(in.Cdc, k) diff --git a/modules/migrationmngr/keeper/abci.go b/modules/migrationmngr/keeper/abci.go index 7ce081f7..3f86197e 100644 --- a/modules/migrationmngr/keeper/abci.go +++ b/modules/migrationmngr/keeper/abci.go @@ -95,17 +95,9 @@ func (k Keeper) EndBlock(ctx context.Context) ([]abci.ValidatorUpdate, error) { } var updates []abci.ValidatorUpdate - if !k.isIBCEnabled(ctx) { - // if IBC is not enabled, we can migrate immediately - // but only return updates on the first block of migration (start height) - if uint64(sdkCtx.BlockHeight()) == start { - updates, err = k.migrateNow(ctx, migration, validatorSet) - if err != nil { - return nil, err - } - } - } else { - updates, err = k.migrateOver(sdkCtx, migration, validatorSet) + // Always perform immediate migration updates at the start height. + if uint64(sdkCtx.BlockHeight()) == start { + updates, err = k.migrateNow(ctx, migration, validatorSet) if err != nil { return nil, err } diff --git a/modules/migrationmngr/keeper/grpc_query_test.go b/modules/migrationmngr/keeper/grpc_query_test.go index 271bd64c..e803238a 100644 --- a/modules/migrationmngr/keeper/grpc_query_test.go +++ b/modules/migrationmngr/keeper/grpc_query_test.go @@ -112,7 +112,6 @@ func initFixture(tb testing.TB) *fixture { storeService, addressCodec, stakingKeeper, - nil, sdk.AccAddress(address.Module(types.ModuleName)).String(), ) @@ -165,41 +164,6 @@ func TestIsMigrating(t *testing.T) { require.Equal(t, uint64(2), resp.EndBlockHeight) } -func TestIsMigrating_IBCEnabled(t *testing.T) { - stakingKeeper := &mockStakingKeeper{} - key := storetypes.NewKVStoreKey(types.ModuleName) - storeService := runtime.NewKVStoreService(key) - encCfg := moduletestutil.MakeTestEncodingConfig(migrationmngr.AppModuleBasic{}) - addressCodec := addresscodec.NewBech32Codec("cosmos") - ibcKey := storetypes.NewKVStoreKey("ibc") - ctx := testutil.DefaultContextWithKeys(map[string]*storetypes.KVStoreKey{ - types.ModuleName: key, - "ibc": ibcKey, - }, nil, nil) - - k := keeper.NewKeeper( - encCfg.Codec, - storeService, - addressCodec, - stakingKeeper, - func() *storetypes.KVStoreKey { return key }, - sdk.AccAddress(address.Module(types.ModuleName)).String(), - ) - - // set up migration - require.NoError(t, k.Migration.Set(ctx, types.EvolveMigration{ - BlockHeight: 1, - Sequencer: types.Sequencer{Name: "foo"}, - })) - - ctx = ctx.WithBlockHeight(1) - resp, err := keeper.NewQueryServer(k).IsMigrating(ctx, &types.QueryIsMigratingRequest{}) - require.NoError(t, err) - require.True(t, resp.IsMigrating) - require.Equal(t, uint64(1), resp.StartBlockHeight) - require.Equal(t, 1+keeper.IBCSmoothingFactor, resp.EndBlockHeight) -} - func TestSequencer_Migrating(t *testing.T) { s := initFixture(t) diff --git a/modules/migrationmngr/keeper/keeper.go b/modules/migrationmngr/keeper/keeper.go index 0d05171f..ffe2aaee 100644 --- a/modules/migrationmngr/keeper/keeper.go +++ b/modules/migrationmngr/keeper/keeper.go @@ -8,24 +8,18 @@ import ( "cosmossdk.io/core/address" corestore "cosmossdk.io/core/store" "cosmossdk.io/log" - storetypes "cosmossdk.io/store/types" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/evstack/ev-abci/modules/migrationmngr/types" ) -// IbcStoreKey is the store key used for IBC-related data. -// It is an alias for storetypes.StoreKey to allow depinject to resolve it as a dependency (as runtime assumes 1 module = 1 store key maximum). -type IbcKVStoreKeyAlias = func() *storetypes.KVStoreKey - type Keeper struct { storeService corestore.KVStoreService cdc codec.BinaryCodec addressCodec address.Codec authority string - ibcStoreKey IbcKVStoreKeyAlias stakingKeeper types.StakingKeeper Schema collections.Schema @@ -40,7 +34,6 @@ func NewKeeper( storeService corestore.KVStoreService, addressCodec address.Codec, stakingKeeper types.StakingKeeper, - ibcStoreKey IbcKVStoreKeyAlias, authority string, ) Keeper { // ensure that authority is a valid account address @@ -55,7 +48,6 @@ func NewKeeper( authority: authority, addressCodec: addressCodec, stakingKeeper: stakingKeeper, - ibcStoreKey: ibcStoreKey, Sequencer: collections.NewItem( sb, types.SequencerKey, @@ -103,13 +95,8 @@ func (k Keeper) IsMigrating(ctx context.Context) (start, end uint64, ok bool) { return 0, 0, false } - // smoothen the migration over IBCSmoothingFactor blocks, in order to migrate the validator set to the sequencer or attesters network when IBC is enabled. - migrationEndHeight := migration.BlockHeight + IBCSmoothingFactor - - // If IBC is not enabled, the migration can be done in one block. - if !k.isIBCEnabled(ctx) { - migrationEndHeight = migration.BlockHeight + 1 - } + // Migration is performed in a single step regardless of IBC. + migrationEndHeight := migration.BlockHeight + 1 sdkCtx := sdk.UnwrapSDKContext(ctx) currentHeight := uint64(sdkCtx.BlockHeight()) @@ -117,28 +104,3 @@ func (k Keeper) IsMigrating(ctx context.Context) (start, end uint64, ok bool) { return migration.BlockHeight, migrationEndHeight, migrationInProgress } - -// isIBCEnabled checks if IBC is enabled on the chain. -// In order to not import the IBC module, we only check if the IBC store exists, -// but not the ibc params. This should be sufficient for our use case. -func (k Keeper) isIBCEnabled(ctx context.Context) bool { - enabled := true - - if k.ibcStoreKey == nil { - return false - } - - sdkCtx := sdk.UnwrapSDKContext(ctx) - - ms := sdkCtx.MultiStore().CacheMultiStore() - defer func() { - if r := recover(); r != nil { - // If we panic, it means the store does not exist, so IBC is not enabled. - enabled = false - } - }() - ms.GetKVStore(k.ibcStoreKey()) - - // has not panicked, so store exists - return enabled -} diff --git a/modules/migrationmngr/keeper/migration.go b/modules/migrationmngr/keeper/migration.go index e06eda88..f3b17b13 100644 --- a/modules/migrationmngr/keeper/migration.go +++ b/modules/migrationmngr/keeper/migration.go @@ -2,9 +2,7 @@ package keeper import ( "context" - "errors" - "cosmossdk.io/collections" abci "github.com/cometbft/cometbft/abci/types" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" @@ -33,7 +31,7 @@ func (k Keeper) migrateNow( if migrationData.StayOnComet { // StayOnComet (IBC disabled): fully undelegate all validators' tokens and // explicitly set the final CometBFT validator set to a single validator with power=1. - k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update (IBC disabled)") + k.Logger(ctx).Info("StayOnComet: immediate undelegation and explicit valset update") // unbond all validator delegations for _, val := range lastValidatorSet { @@ -150,199 +148,6 @@ func migrateToAttesters( return initialValUpdates, nil } -// migrateOver migrates the chain to evolve over a period of blocks. -// this is to ensure ibc light client verification keep working while changing the whole validator set. -// the migration step is tracked in store. -// If StayOnComet is true, delegations are unbonded gradually and empty updates returned. -// Otherwise, ABCI ValidatorUpdates are returned directly for rollup migration. -func (k Keeper) migrateOver( - ctx context.Context, - migrationData types.EvolveMigration, - lastValidatorSet []stakingtypes.Validator, -) (initialValUpdates []abci.ValidatorUpdate, err error) { - step, err := k.MigrationStep.Get(ctx) - if err != nil && !errors.Is(err, collections.ErrNotFound) { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get migration step: %v", err) - } - - if step >= IBCSmoothingFactor { - // migration complete - if err := k.MigrationStep.Remove(ctx); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to remove migration step: %v", err) - } - - if migrationData.StayOnComet { - // unbonding was already completed gradually over previous blocks, just return empty updates - k.Logger(ctx).Info("Migration complete, all validators unbonded gradually") - return []abci.ValidatorUpdate{}, nil - } - - // rollup migration: return final ABCI validator updates - return k.migrateNow(ctx, migrationData, lastValidatorSet) - } - - if migrationData.StayOnComet { - // StayOnComet with IBC enabled: from the very first smoothing step, keep - // membership constant and reweight CometBFT powers so that the sequencer - // alone has >1/3 voting power. This removes timing sensitivity for IBC - // client updates. - - // Final step: set sequencer power=1 and undelegate sequencer - if step+1 == IBCSmoothingFactor { - k.Logger(ctx).Info("StayOnComet: finalization step, setting sequencer power=1 and undelegating all delegations") - - for _, val := range lastValidatorSet { - if err := k.unbondValidatorDelegations(ctx, val); err != nil { - return nil, err - } - } - - // ABCI updates: zero all non-sequencers, set sequencer to 1 - var updates []abci.ValidatorUpdate - validatorsToRemove := getValidatorsToRemove(migrationData, lastValidatorSet) - for _, val := range validatorsToRemove { - updates = append(updates, val.ABCIValidatorUpdateZero()) - } - pk, err := migrationData.Sequencer.TmConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get sequencer pubkey: %v", err) - } - updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: 1}) - - // increment step to mark completion next block - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - return updates, nil - } - - // emit reweighting updates: ensure sequencer gets large power, others get 1. - n := len(lastValidatorSet) - if n == 0 { - return []abci.ValidatorUpdate{}, nil - } - seqPower := int64(2 * n) - var updates []abci.ValidatorUpdate - for _, val := range lastValidatorSet { - pk, err := val.CmtConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get validator pubkey: %v", err) - } - power := int64(1) - if val.ConsensusPubkey.Equal(migrationData.Sequencer.ConsensusPubkey) { - power = seqPower - } - updates = append(updates, abci.ValidatorUpdate{PubKey: pk, Power: power}) - } - - // advance smoothing step - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - return updates, nil - } - - // rollup migration: build and return ABCI updates directly - switch len(migrationData.Attesters) { - case 0: - // no attesters, migrate to a single sequencer over smoothing period - // remove all validators except the sequencer, add sequencer at the end - seq := migrationData.Sequencer - var oldValsToRemove []stakingtypes.Validator - for _, val := range lastValidatorSet { - if !val.ConsensusPubkey.Equal(seq.ConsensusPubkey) { - oldValsToRemove = append(oldValsToRemove, val) - } - } - removePerStep := (len(oldValsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - startRemove := int(step) * removePerStep - endRemove := min(startRemove+removePerStep, len(oldValsToRemove)) - for _, val := range oldValsToRemove[startRemove:endRemove] { - powerUpdate := val.ABCIValidatorUpdateZero() - initialValUpdates = append(initialValUpdates, powerUpdate) - } - default: - // attesters present, migrate as before - attesterPubKeys := make(map[string]struct{}) - for _, attester := range migrationData.Attesters { - attesterPubKeys[attester.ConsensusPubkey.String()] = struct{}{} - } - var oldValsToRemove []stakingtypes.Validator - for _, val := range lastValidatorSet { - if _, ok := attesterPubKeys[val.ConsensusPubkey.String()]; !ok { - oldValsToRemove = append(oldValsToRemove, val) - } - } - lastValPubKeys := make(map[string]struct{}) - for _, val := range lastValidatorSet { - lastValPubKeys[val.ConsensusPubkey.String()] = struct{}{} - } - var newAttestersToAdd []types.Attester - for _, attester := range migrationData.Attesters { - if _, ok := lastValPubKeys[attester.ConsensusPubkey.String()]; !ok { - newAttestersToAdd = append(newAttestersToAdd, attester) - } - } - removePerStep := (len(oldValsToRemove) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - addPerStep := (len(newAttestersToAdd) + int(IBCSmoothingFactor) - 1) / int(IBCSmoothingFactor) - startRemove := int(step) * removePerStep - endRemove := min(startRemove+removePerStep, len(oldValsToRemove)) - for _, val := range oldValsToRemove[startRemove:endRemove] { - powerUpdate := val.ABCIValidatorUpdateZero() - initialValUpdates = append(initialValUpdates, powerUpdate) - } - startAdd := int(step) * addPerStep - endAdd := min(startAdd+addPerStep, len(newAttestersToAdd)) - for _, attester := range newAttestersToAdd[startAdd:endAdd] { - pk, err := attester.TmConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get attester pubkey: %v", err) - } - attesterUpdate := abci.ValidatorUpdate{ - PubKey: pk, - Power: 1, - } - initialValUpdates = append(initialValUpdates, attesterUpdate) - } - } - - // increment and persist the step - if err := k.MigrationStep.Set(ctx, step+1); err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to set migration step: %v", err) - } - - // the first time, we set the whole validator set to the same validator power. This is to avoid a validator ends up with >= 33% or worse >= 66% - // vp during the migration. - // TODO: add a test - if step == 0 { - // Create a map of existing updates for O(1) lookup - existingUpdates := make(map[string]bool) - for _, powerUpdate := range initialValUpdates { - existingUpdates[powerUpdate.PubKey.String()] = true - } - - // set the whole validator set to the same power - for _, val := range lastValidatorSet { - valPubKey, err := val.CmtConsPublicKey() - if err != nil { - return nil, sdkerrors.ErrInvalidRequest.Wrapf("failed to get validator pubkey: %v", err) - } - - if !existingUpdates[valPubKey.String()] { - powerUpdate := abci.ValidatorUpdate{ - PubKey: valPubKey, - Power: 1, - } - initialValUpdates = append(initialValUpdates, powerUpdate) - } - } - } - - return initialValUpdates, nil -} - // unbondValidatorDelegations unbonds all delegations to a specific validator. // This is used when StayOnComet is true to properly return tokens to delegators. func (k Keeper) unbondValidatorDelegations(ctx context.Context, validator stakingtypes.Validator) error { diff --git a/scripts/e2e/Makefile b/scripts/e2e/Makefile deleted file mode 100644 index 5653004f..00000000 --- a/scripts/e2e/Makefile +++ /dev/null @@ -1,95 +0,0 @@ -SHELL := /bin/bash - -COMPOSE := docker compose -f docker-compose.yml - -# Default ports exposed in compose -RPC_A ?= 26657 -GRPC_A ?= 9090 -RPC_B ?= 26657 -GRPC_B ?= 9090 -RPC_C ?= 26657 -GRPC_C ?= 9090 - -# Migration window and height (set MIG when running backfill) -WINDOW ?= 30 -MIG ?= - -.PHONY: help -help: - @echo "E2E orchestration targets:" - @echo " make up - start chains (wait for height >= 1)" - @echo " make down - stop containers" - @echo " make ps - list containers" - @echo " make logs-a/b/c - tail chain logs" - @echo " make hermes-start - start Hermes only (after chains ready)" - @echo " make ibc-setup - fund relayer and create A<->B, A<->C channels" - @echo " make migrate - submit StayOnComet migration on A (WINDOW=$(WINDOW))" - @echo " make backfill MIG= [WINDOW=$(WINDOW)] - run client backfill" - @echo " make clean - down and remove volumes" - -.PHONY: up -up: - $(COMPOSE) up -d gm-a-val-0 gm-a-val-1 gm-a-val-2 gm-b-val-0 gm-c-val-0 hermes - @echo "[wait] waiting for chains to reach height >= 1" - @bash -c 'set -e; \ - parse_height() { \ - url="$$1"; \ - if command -v jq >/dev/null 2>&1; then \ - curl -s "$$url" | jq -r .result.sync_info.latest_block_height 2>/dev/null || true; \ - else \ - curl -s "$$url" | sed -n "s/.*\"latest_block_height\":\"\([0-9]\+\)\".*/\1/p"; \ - fi; \ - }; \ - endpoints=("http://localhost:26657/status" "http://localhost:27657/status" "http://localhost:28657/status"); \ - for ep in "$${endpoints[@]}"; do \ - echo " waiting on $$ep"; \ - ok=0; \ - for i in $$(seq 1 120); do \ - h=$$(parse_height $$ep); \ - if [ -n "$$h" ] && [ "$$h" -ge 1 ] 2>/dev/null; then \ - echo " ok height=$$h"; ok=1; break; \ - fi; \ - sleep 1; \ - done; \ - if [ $$ok -ne 1 ]; then echo " timeout waiting for $$ep" >&2; exit 1; fi; \ - done' - @echo "[up] Hermes started (gated by chain healthchecks)" - -.PHONY: down -down: - $(COMPOSE) down - -.PHONY: ps -ps: - $(COMPOSE) ps - -.PHONY: logs-a logs-b logs-c -logs-a: - docker logs -f gm-a-val-0 -logs-b: - docker logs -f gm-b-val-0 -logs-c: - docker logs -f gm-c-val-0 - -.PHONY: init - -.PHONY: hermes-start -hermes-start: - $(COMPOSE) up -d hermes - -.PHONY: ibc-setup -ibc-setup: - bash ./ibc-setup.sh - -.PHONY: migrate -migrate: - WINDOW=$(WINDOW) bash ./migrate-stayoncomet.sh $(WINDOW) - -.PHONY: backfill -backfill: - @if [ -z "$(MIG)" ]; then echo "Set MIG= (e.g., MIG=120)" >&2; exit 2; fi - python3 ./update_ibc_clients_backfill.py gm-a gm-b gm-c --migration-height $(MIG) --window $(WINDOW) - -.PHONY: clean -clean: - $(COMPOSE) down -v diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md deleted file mode 100644 index 7ba852e9..00000000 --- a/scripts/e2e/README.md +++ /dev/null @@ -1,42 +0,0 @@ -Local 3-Chain + Hermes E2E (StayOnComet) - -What this provides -- 3 Cosmos-SDK chains: gm-a, gm-b, gm-c (single validator each by default) -- One Hermes relayer configured for all chains -- ICS20 channels between A<->B and A<->C -- A governance-driven migration on chain A with StayOnComet=true -- A Python backfill script to advance IBC clients across the smoothing window -- Simple transfer verification after the migration - -Quick start -1) Spin up containers - - From repo root: docker compose -f scripts/e2e/docker-compose.yml up -d - -2) Initialize chains (single validator each) and start nodes - - scripts/e2e/init-chain.sh gm-a-val-0 gm-a 26657 9090 - - scripts/e2e/init-chain.sh gm-b-val-0 gm-b 26657 9090 - - scripts/e2e/init-chain.sh gm-c-val-0 gm-c 26657 9090 - -3) Configure Hermes and add keys - - scripts/e2e/hermes-init.sh - -4) Fund Hermes relayer addresses and create IBC channels (A<->B, A<->C) - - scripts/e2e/ibc-setup.sh - -5) Submit StayOnComet migration proposal on A - - scripts/e2e/migrate-stayoncomet.sh 30 - - Wait until chain A reaches migration_height + window - -6) Backfill client updates across the window - - python3 scripts/e2e/update_ibc_clients_backfill.py gm-a gm-b gm-c --migration-height --window 30 - -7) Verify IBC with a test transfer (e.g., from gm-a to gm-b) - - Use gmd tx ibc-transfer transfer ... on gm-a and watch Hermes relay - -Notes -- The compose file exposes RPC+gRPC for all chains on host ports: - - gm-a: RPC 26657, gRPC 9090 - - gm-b: RPC 27657, gRPC 9190 - - gm-c: RPC 28657, gRPC 9290 -- This scaffold initializes one validator per chain by default. If you need gm-a with 3 validators, you can extend the initialization to produce a multi-validator genesis and distribute it to gm-a-val-1/2; or start gm-a-val-1/2 as additional nodes and use staking txs to promote them to validators. The StayOnComet overlap logic is compatible with single or multiple validators. - diff --git a/scripts/e2e/chain-entry.sh b/scripts/e2e/chain-entry.sh deleted file mode 100644 index e7b8ece9..00000000 --- a/scripts/e2e/chain-entry.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -set -e - -CHAIN_ID="${CHAIN_ID:-gm-a}" -RPC_PORT="${RPC_PORT:-26657}" -GRPC_PORT="${GRPC_PORT:-9090}" -DENOM="stake" -HOME_DIR="/home/gm/.gm" -MNEMONIC_FILE="/scripts/relayer.mnemonic" - -echo "[chain] init $CHAIN_ID (rpc=$RPC_PORT grpc=$GRPC_PORT)" - -mkdir -p "$HOME_DIR" "$HOME_DIR/config" - -if [ ! -f "$HOME_DIR/config/genesis.json" ]; then - gmd init "$HOSTNAME" --chain-id "$CHAIN_ID" --home "$HOME_DIR" - - # faucet key (ensure it exists) -if ! gmd keys show faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then - gmd keys add faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1 -fi - FAUCET_ADDR=$(gmd keys show faucet -a --keyring-backend test --home "$HOME_DIR") - - # add faucet genesis account by explicit address - if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis add-genesis-account "$FAUCET_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" - else - gmd add-genesis-account "$FAUCET_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" - fi - - # pre-fund relayer account from shared mnemonic (same address on all chains as Hermes) - if [ -f "$MNEMONIC_FILE" ]; then - if ! gmd keys show relayer --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then - # recover relayer key non-interactively from mnemonic file - gmd keys add relayer --recover --keyring-backend test --home "$HOME_DIR" < "$MNEMONIC_FILE" >/dev/null 2>&1 - fi - RELAYER_ADDR=$(gmd keys show relayer -a --keyring-backend test --home "$HOME_DIR" 2>/dev/null || true) - if [ -n "$RELAYER_ADDR" ]; then - if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis add-genesis-account "$RELAYER_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" - else - gmd add-genesis-account "$RELAYER_ADDR" 100000000000"$DENOM" --home "$HOME_DIR" - fi - fi - fi - - # gentx + collect - if gmd genesis gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis gentx faucet 10000000000"$DENOM" --moniker "$HOSTNAME" --chain-id "$CHAIN_ID" --keyring-backend test --home "$HOME_DIR" - gmd genesis collect-gentxs --home "$HOME_DIR" - else - gmd gentx faucet 10000000000"$DENOM" --moniker "$HOSTNAME" --chain-id "$CHAIN_ID" --keyring-backend test --home "$HOME_DIR" - gmd collect-gentxs --home "$HOME_DIR" - fi - - # config - sed -i "s/^minimum-gas-prices.*/minimum-gas-prices = \"0.0$DENOM\"/" "$HOME_DIR/config/app.toml" - sed -i "s/^indexer.*/indexer = \"kv\"/" "$HOME_DIR/config/config.toml" - sed -i "s|127.0.0.1:26657|0.0.0.0:26657|" "$HOME_DIR/config/config.toml" - sed -i "s|127.0.0.1:9090|0.0.0.0:9090|" "$HOME_DIR/config/app.toml" -fi - -echo "[chain] starting $CHAIN_ID" -exec gmd start --home "$HOME_DIR" --rpc.laddr tcp://0.0.0.0:"$RPC_PORT" --grpc.address 0.0.0.0:"$GRPC_PORT" diff --git a/scripts/e2e/docker-compose.yml b/scripts/e2e/docker-compose.yml deleted file mode 100644 index b3e584f1..00000000 --- a/scripts/e2e/docker-compose.yml +++ /dev/null @@ -1,125 +0,0 @@ -version: "3.8" - -services: - gm-a-val-0: - image: cosmos-gm:test - container_name: gm-a-val-0 - entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] - volumes: - - gm_a_val0:/home/gm/.gm - - ./:/scripts:ro - networks: [gmnet] - user: "0:0" - ports: - - "26657:26657" # RPC - - "9090:9090" # gRPC - environment: - - CHAIN_ID=gm-a - - RPC_PORT=26657 - - GRPC_PORT=9090 - healthcheck: - test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] - interval: 2s - timeout: 2s - retries: 60 - - gm-a-val-1: - image: cosmos-gm:test - container_name: gm-a-val-1 - entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] - volumes: - - gm_a_val1:/home/gm/.gm - - ./:/scripts:ro - networks: [gmnet] - user: "0:0" - environment: - - CHAIN_ID=gm-a - - RPC_PORT=26657 - - GRPC_PORT=9090 - - gm-a-val-2: - image: cosmos-gm:test - container_name: gm-a-val-2 - entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] - volumes: - - gm_a_val2:/home/gm/.gm - - ./:/scripts:ro - networks: [gmnet] - user: "0:0" - environment: - - CHAIN_ID=gm-a - - RPC_PORT=26657 - - GRPC_PORT=9090 - - gm-b-val-0: - image: cosmos-gm:test - container_name: gm-b-val-0 - entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] - volumes: - - gm_b_val0:/home/gm/.gm - - ./:/scripts:ro - networks: [gmnet] - user: "0:0" - ports: - - "27657:26657" - - "9190:9090" - environment: - - CHAIN_ID=gm-b - - RPC_PORT=26657 - - GRPC_PORT=9090 - healthcheck: - test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] - interval: 2s - timeout: 2s - retries: 60 - - gm-c-val-0: - image: cosmos-gm:test - container_name: gm-c-val-0 - entrypoint: ["/bin/sh", "/scripts/chain-entry.sh"] - volumes: - - gm_c_val0:/home/gm/.gm - - ./:/scripts:ro - networks: [gmnet] - user: "0:0" - ports: - - "28657:26657" - - "9290:9090" - environment: - - CHAIN_ID=gm-c - - RPC_PORT=26657 - - GRPC_PORT=9090 - healthcheck: - test: ["CMD-SHELL", "gmd status 2>/dev/null | tr -d '\n' | grep -q '\"latest_block_height\":\"[1-9]' "] - interval: 2s - timeout: 2s - retries: 60 - - hermes: - image: ghcr.io/informalsystems/hermes:1.13.1 - container_name: hermes - entrypoint: ["/bin/sh", "/scripts/hermes-entry.sh"] - volumes: - - hermes_home:/home/hermes/.hermes - - ./:/scripts:ro - networks: [gmnet] - depends_on: - gm-a-val-0: - condition: service_healthy - gm-b-val-0: - condition: service_healthy - gm-c-val-0: - condition: service_healthy - restart: unless-stopped - user: "0:0" - -networks: - gmnet: {} - -volumes: - gm_a_val0: {} - gm_a_val1: {} - gm_a_val2: {} - gm_b_val0: {} - gm_c_val0: {} - hermes_home: {} diff --git a/scripts/e2e/hermes-entry.sh b/scripts/e2e/hermes-entry.sh deleted file mode 100644 index 8f9186c7..00000000 --- a/scripts/e2e/hermes-entry.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/bin/sh -set -e - -CONFIG=/home/hermes/.hermes/config.toml -MNEMONIC=/scripts/relayer.mnemonic - -# Ensure config dir exists and is writable -mkdir -p /home/hermes/.hermes || true - -# Point Hermes to the generated config for all subsequent CLI calls -export HERMES_HOME=/home/hermes/.hermes -export HERMES_CONFIG="$CONFIG" - -cat > "$CONFIG" </dev/null | grep -q "$name"; then - echo " [$ch] key $name already present" - continue - fi - echo " [$ch] importing $name from mnemonic" - hermes --config "$CONFIG" keys add --chain "$ch" --mnemonic-file "$MNEMONIC" --key-name "$name" -done - -# Create connections + channels (idempotent) -echo "[hermes] creating IBC connections/channels" -# A <-> B -hermes --config "$CONFIG" create connection --a-chain gm-a --b-chain gm-b || true -hermes --config "$CONFIG" create channel \ - --a-chain gm-a --b-chain gm-b \ - --a-port transfer --b-port transfer \ - --order unordered --channel-version ics20-1 \ - --new-client-connection || true -# A <-> C -hermes --config "$CONFIG" create connection --a-chain gm-a --b-chain gm-c || true -hermes --config "$CONFIG" create channel \ - --a-chain gm-a --b-chain gm-c \ - --a-port transfer --b-port transfer \ - --order unordered --channel-version ics20-1 \ - --new-client-connection || true - -echo "[hermes] starting relayer" -exec hermes --config "$CONFIG" start diff --git a/scripts/e2e/hermes-init.sh b/scripts/e2e/hermes-init.sh deleted file mode 100644 index 4f5f8dd4..00000000 --- a/scripts/e2e/hermes-init.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -HERMES="docker exec -i hermes hermes" - -cat > /tmp/hermes-config.toml <<'EOF' -[global] -resolver = "gethostbyname" -log_level = "info" - -[mode] -clients = { enabled = true, refresh = true, misbehaviour = true } -connections = { enabled = true } -channels = { enabled = true } -packets = { enabled = true, clear_on_start = true } - -[rest] -enabled = true -host = "0.0.0.0" -port = 3000 - -[telemetry] -enabled = false - -[[chains]] -id = "gm-a" -rpc_addr = "http://gm-a-val-0:26657" -grpc_addr = "http://gm-a-val-0:9090" -event_source = { mode = "pull", interval = "1s" } -clock_drift = "60s" -trusted_node = false -account_prefix = "gm" -key_name = "relayer-gm-a" -store_prefix = "ibc" -default_gas = 250000 -max_gas = 400000 -gas_price = { price = 0.0, denom = "stake" } - -[[chains]] -id = "gm-b" -rpc_addr = "http://gm-b-val-0:26657" -grpc_addr = "http://gm-b-val-0:9090" -event_source = { mode = "pull", interval = "1s" } -clock_drift = "60s" -trusted_node = false -account_prefix = "gm" -key_name = "relayer-gm-b" -store_prefix = "ibc" -default_gas = 250000 -max_gas = 400000 -gas_price = { price = 0.0, denom = "stake" } - -[[chains]] -id = "gm-c" -rpc_addr = "http://gm-c-val-0:26657" -grpc_addr = "http://gm-c-val-0:9090" -event_source = { mode = "pull", interval = "1s" } -clock_drift = "60s" -trusted_node = false -account_prefix = "gm" -key_name = "relayer-gm-c" -store_prefix = "ibc" -default_gas = 250000 -max_gas = 400000 -gas_price = { price = 0.0, denom = "stake" } -EOF - -docker cp /tmp/hermes-config.toml hermes:/home/hermes/.hermes/config.toml - -# create keys and show addresses to fund -for ch in gm-a gm-b gm-c; do - $HERMES keys add --chain "$ch" --mnemonic-file /dev/null >/dev/null 2>&1 || true - $HERMES keys list --chain "$ch" -done - -echo "[ok] Hermes config written and keys added. Next: run scripts/e2e/ibc-setup.sh" - diff --git a/scripts/e2e/ibc-setup.sh b/scripts/e2e/ibc-setup.sh deleted file mode 100644 index 2ee64d4c..00000000 --- a/scripts/e2e/ibc-setup.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -HERMES="docker exec -i hermes hermes --config /home/hermes/.hermes/config.toml" - -fund() { - local node="$1" chain_id="$2" key_name="$3" - local addr - addr=$($HERMES keys list --chain "$chain_id" | awk '/address/ {print $2; exit}' | tr -d '\r') - echo "[fund] $chain_id -> $addr" - docker exec -i "$node" sh -lc "gmd tx bank send faucet $addr 500000000stake --chain-id $chain_id --keyring-backend test --yes" -} - -# fund relayer addresses -fund gm-a-val-0 gm-a relayer-gm-a -fund gm-b-val-0 gm-b relayer-gm-b -fund gm-c-val-0 gm-c relayer-gm-c - -# give it a few blocks -sleep 3 - -# create connections + channels -$HERMES create connection --a-chain gm-a --b-chain gm-b -$HERMES create channel --a-chain gm-a --a-port transfer --b-port transfer --order unordered --channel-version ics20-1 - -$HERMES create connection --a-chain gm-a --b-chain gm-c -$HERMES create channel --a-chain gm-a --a-port transfer --b-port transfer --order unordered --channel-version ics20-1 - -echo "[ok] IBC connections and channels created between A<->B and A<->C" diff --git a/scripts/e2e/init-chain.sh b/scripts/e2e/init-chain.sh deleted file mode 100644 index 2d4d0ea7..00000000 --- a/scripts/e2e/init-chain.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Initialize and start a single-validator chain inside a container. -# Usage: ./init-chain.sh [rpc_port=26657] [grpc_port=9090] - -NODE="${1:-}" -CHAIN_ID="${2:-}" -RPC_PORT="${3:-26657}" -GRPC_PORT="${4:-9090}" - -if [[ -z "$NODE" || -z "$CHAIN_ID" ]]; then - echo "usage: $0 [rpc_port] [grpc_port]" >&2 - exit 2 -fi - -DENOM=stake - -echo "[init] $NODE ($CHAIN_ID)" -docker exec -u 0 -i "$NODE" sh -lc ' -set -e -HOME_DIR=/home/gm/.gm -mkdir -p "$HOME_DIR" -chmod -R 777 "$HOME_DIR" || true -if [ ! -d "$HOME_DIR/config" ]; then - gmd init '"$NODE"' --chain-id '"$CHAIN_ID"' --home "$HOME_DIR" -fi - -# faucet key -if ! gmd keys show faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1; then - gmd keys add faucet --keyring-backend test --home "$HOME_DIR" >/dev/null 2>&1 -fi - -FAUCET_ADDR=$(gmd keys show faucet -a --keyring-backend test --home "$HOME_DIR") -# fund faucet (prefer new genesis subcommand, fallback to legacy top-level) -if gmd genesis add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis add-genesis-account "$FAUCET_ADDR" 100000000000'"$DENOM"' --home "$HOME_DIR" -elif gmd add-genesis-account --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd add-genesis-account "$FAUCET_ADDR" 100000000000'"$DENOM"' --home "$HOME_DIR" -fi - -# gentx if not present (handle SDK CLI variants) -if [ -z "$(ls -1 "$HOME_DIR"/config/gentx 2>/dev/null | head -n1)" ]; then - if gmd genesis gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis gentx faucet --amount 10000000000'"$DENOM"' --chain-id '"$CHAIN_ID"' --keyring-backend test --home "$HOME_DIR" - elif gmd gentx --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd gentx faucet 10000000000'"$DENOM"' --chain-id '"$CHAIN_ID"' --keyring-backend test --home "$HOME_DIR" - fi -fi - -# collect gentxs (prefer new genesis subcommand) -if gmd genesis collect-gentxs --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd genesis collect-gentxs --home "$HOME_DIR" -elif gmd collect-gentxs --help --home "$HOME_DIR" >/dev/null 2>&1; then - gmd collect-gentxs --home "$HOME_DIR" -fi - -# app/config tweaks -sed -i "s/^minimum-gas-prices.*/minimum-gas-prices = \"0.0'"$DENOM"'\"/" "$HOME_DIR"/config/app.toml -sed -i "s/^indexer.*/indexer = \"kv\"/" "$HOME_DIR"/config/config.toml -sed -i "s|127.0.0.1:26657|0.0.0.0:26657|" "$HOME_DIR"/config/config.toml -sed -i "s|127.0.0.1:9090|0.0.0.0:9090|" "$HOME_DIR"/config/app.toml - -# reduce voting period for testing (30 seconds instead of 48 hours) -sed -i "s/\"voting_period\": \"172800s\"/\"voting_period\": \"30s\"/" "$HOME_DIR"/config/genesis.json -sed -i "s/\"expedited_voting_period\": \"86400s\"/\"expedited_voting_period\": \"15s\"/" "$HOME_DIR"/config/genesis.json -sed -i "s/\"max_deposit_period\": \"172800s\"/\"max_deposit_period\": \"30s\"/" "$HOME_DIR"/config/genesis.json - -# start -nohup gmd start --home "$HOME_DIR" --rpc.laddr tcp://0.0.0.0:'"$RPC_PORT"' --grpc.address 0.0.0.0:'"$GRPC_PORT"' \ - >/home/gm/node.log 2>&1 & -echo started -' diff --git a/scripts/e2e/migrate-stayoncomet.sh b/scripts/e2e/migrate-stayoncomet.sh deleted file mode 100755 index d50a54ef..00000000 --- a/scripts/e2e/migrate-stayoncomet.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# migrate to single validator (validator-0) while staying on CometBFT -# hardcoded for gm-a-val-0 container - -NODE="gm-a-val-0" -CHAIN_ID="gm-a" -KEY="faucet" -HOME_DIR="/home/gm/.gm" -VALIDATORS=("gm-a-val-0" "gm-a-val-1" "gm-a-val-2") - -echo "[migrate-stayoncomet] getting current height..." -CURRENT_HEIGHT=$(docker exec "$NODE" sh -lc "gmd status --home $HOME_DIR" | grep -o '"latest_block_height":"[0-9]*"' | grep -o '[0-9]*') -MIGRATE_AT=$((CURRENT_HEIGHT + 30)) - -echo "[migrate-stayoncomet] current height: $CURRENT_HEIGHT, migration at: $MIGRATE_AT" - -echo "[migrate-stayoncomet] getting validator-0 pubkey..." -VALIDATOR_PUBKEY=$(docker exec "$NODE" sh -lc "gmd query staking validators --home $HOME_DIR --output json" | grep -A 2 '"consensus_pubkey"' | grep '"value"' | head -1 | sed 's/.*"value": "\(.*\)".*/\1/') - -echo "[migrate-stayoncomet] validator pubkey: $VALIDATOR_PUBKEY" - -echo "[migrate-stayoncomet] using hardcoded gov module address..." -GOV_ADDRESS="gm10d07y265gmmuvt4z0w9aw880jnsr700j5nsal6" - -echo "[migrate-stayoncomet] gov address: $GOV_ADDRESS" - -echo "[migrate-stayoncomet] creating proposal json..." -cat > /tmp/proposal.json < /tmp/submit_output.json 2>&1 - -SUBMIT_OUTPUT=$(cat /tmp/submit_output.json) -echo "[migrate-stayoncomet] submit output: $SUBMIT_OUTPUT" - -TXHASH=$(echo "$SUBMIT_OUTPUT" | grep -o '"txhash":"[^"]*"' | cut -d'"' -f4) -echo "[migrate-stayoncomet] tx hash: $TXHASH" - -echo "[migrate-stayoncomet] waiting for tx to be committed..." -sleep 5 - -echo "[migrate-stayoncomet] querying proposal ID from transaction..." -PROPOSAL_ID=$(docker exec "$NODE" sh -lc "gmd query tx $TXHASH --home $HOME_DIR --output json 2>/dev/null" | grep -o '"key":"proposal_id","value":"[0-9]*"' | grep -o '[0-9]*' | head -1) - -if [ -z "$PROPOSAL_ID" ]; then - echo "[migrate-stayoncomet] failed to extract proposal ID, querying all proposals..." - sleep 3 - PROPOSAL_ID=$(docker exec "$NODE" sh -lc "gmd query gov proposals --home $HOME_DIR --output json" | grep -o '\"id\":\"[0-9]*\"' | tail -1 | grep -o '[0-9]*') -fi - -echo "[migrate-stayoncomet] proposal ID: $PROPOSAL_ID" - -echo "[migrate-stayoncomet] waiting for proposal to be queryable..." -sleep 3 - -echo "[migrate-stayoncomet] voting yes on proposal from all validators..." -for val in "${VALIDATORS[@]}"; do - echo "[migrate-stayoncomet] voting from $val..." - docker exec "$val" sh -lc "gmd tx gov vote $PROPOSAL_ID yes \ - --from $KEY \ - --keyring-backend test \ - --chain-id $CHAIN_ID \ - --home $HOME_DIR \ - --yes" >/dev/null 2>&1 || echo "[migrate-stayoncomet] vote from $val may have failed" -done - -echo "[migrate-stayoncomet] waiting for votes to be processed..." -sleep 3 - -echo "[migrate-stayoncomet] proposal submitted and voted on" -echo "[migrate-stayoncomet] proposal ID: $PROPOSAL_ID" -echo "[migrate-stayoncomet] migration will execute at height $MIGRATE_AT" diff --git a/scripts/e2e/relayer.mnemonic b/scripts/e2e/relayer.mnemonic deleted file mode 100644 index f1032c1a..00000000 --- a/scripts/e2e/relayer.mnemonic +++ /dev/null @@ -1 +0,0 @@ -abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about diff --git a/scripts/e2e/update_ibc_clients_backfill.py b/scripts/e2e/update_ibc_clients_backfill.py deleted file mode 100644 index fe52d9d9..00000000 --- a/scripts/e2e/update_ibc_clients_backfill.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import subprocess -import sys -import time -from typing import List, Tuple, Set - - -def log(msg: str) -> None: - ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - print(f"{ts} {msg}", file=sys.stderr) - - -def run_hermes(args: List[str], timeout: float = 60.0) -> dict: - cmd = ["hermes", "--json"] + args - res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - if res.returncode != 0: - err = (res.stderr or res.stdout or "").strip() - raise subprocess.CalledProcessError(res.returncode, cmd, output=res.stdout, stderr=err) - try: - return json.loads(res.stdout) - except json.JSONDecodeError as e: - raise RuntimeError( - f"failed to decode hermes JSON for {cmd}: {e}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}" - ) - - -def ensure_success(obj: dict, ctx: str) -> None: - st = obj.get("status") - if st and str(st).lower() != "success": - raise RuntimeError(f"{ctx}: status={st}") - - -def query_channels(chain: str) -> List[dict]: - data = run_hermes(["query", "channels", "--chain", chain], timeout=90.0) - ensure_success(data, f"query channels {chain}") - result = data.get("result", []) - return result if isinstance(result, list) else [] - - -def query_connection_end(chain: str, connection: str) -> dict: - data = run_hermes(["query", "connection", "end", "--chain", chain, "--connection", connection]) - ensure_success(data, f"query connection end {chain}/{connection}") - return data - - -def extract_client_ids(conn_end: dict) -> Tuple[str, str]: - # Returns (client_id_on_A, client_id_on_B) (B is counterparty's client on host chain) - root = conn_end.get("result") or {} - conn = root.get("connection") or {} - end = conn.get("end") or {} - client_a = end.get("client_id") or conn.get("client_id") or "" - cp = end.get("counterparty") or conn.get("counterparty") or {} - - if not client_a or not cp.get("client_id"): - # Fallback: connection_end - alt = root.get("connection_end") or {} - end2 = alt or {} - client_a = client_a or end2.get("client_id") or "" - cp = cp or end2.get("counterparty") or {} - - client_b = cp.get("client_id") or "" - return client_a, client_b - - -def query_client_state(chain: str, client_id: str) -> dict: - data = run_hermes(["query", "client", "state", "--chain", chain, "--client", client_id]) - ensure_success(data, f"query client state {chain}/{client_id}") - return data - - -def extract_chain_id(client_state: dict) -> str: - result = client_state.get("result") or [] - if not isinstance(result, list) or not result: - return "" - entry = result[0] - cs = entry.get("client_state") or entry.get("ClientState") or {} - return cs.get("chain_id") or "" - - -def extract_revision_height(client_state: dict) -> int: - result = client_state.get("result") or [] - if not isinstance(result, list) or not result: - return 0 - entry = result[0] - cs = entry.get("client_state") or entry.get("ClientState") or {} - lh = cs.get("latest_height") or {} - rh = lh.get("revision_height") - if isinstance(rh, int): - return rh - if isinstance(rh, str) and rh.isdigit(): - return int(rh) - try: - return int(str(rh)) - except Exception: - return 0 - - -def update_client(host_chain: str, client_id: str, height: int = None) -> None: - args = ["update", "client", "--host-chain", host_chain, "--client", client_id] - if height is not None: - args += ["--height", str(height)] - run_hermes(args, timeout=120.0) - - -def discover_host_clients(chains: List[str]) -> List[Tuple[str, str, str]]: - """ - Returns unique triples: (host_chain, client_id_on_host, subject_chain_id). - host_chain = where the client lives (counterparty), subject_chain_id = chain tracked by client. - """ - seen: Set[Tuple[str, str]] = set() - triples: List[Tuple[str, str, str]] = [] - - for chain in chains: - log(f"[info] scanning channels on {chain}") - try: - channels = query_channels(chain) - except Exception as e: - log(f"[warn] failed to query channels on {chain}: {e}") - continue - - for idx, ch in enumerate(channels): - hops = ch.get("connection_hops") or [] - port = ch.get("port_id") or "" - chan = ch.get("channel_id") or "" - if not hops or not port or not chan: - log(f"[warn] {chain} channel[{idx}] missing connection/port/channel; skipping") - continue - connA = hops[0] - log(f"[info] {chain} {port}/{chan} via {connA}") - - try: - conn_end = query_connection_end(chain, connA) - except Exception as e: - log(f"[warn] failed to query connection end {connA} on {chain}: {e}") - continue - - client_id_a, client_id_b = extract_client_ids(conn_end) - if not client_id_a or not client_id_b: - log(f"[warn] missing client IDs for {chain}/{connA}; skipping") - continue - - try: - cstate = query_client_state(chain, client_id_a) - except Exception as e: - log(f"[warn] failed to query client state {client_id_a} on {chain}: {e}") - continue - - subject_chain = extract_chain_id(cstate) - if not subject_chain: - log(f"[warn] could not determine counterparty chain-id for {chain}/{connA} (client {client_id_a}); skipping") - continue - - host_chain = subject_chain - key = (host_chain, client_id_b) - if key in seen: - continue - seen.add(key) - triples.append((host_chain, client_id_b, subject_chain)) - log(f"[info] discovered host={host_chain} client={client_id_b} (tracks {subject_chain})") - - return triples - - -def backfill_client(host_chain: str, client_id: str, end_height: int) -> None: - try: - cstate = query_client_state(host_chain, client_id) - except Exception as e: - log(f"[error] query client state failed on host={host_chain} client={client_id}: {e}") - return - - trusted = extract_revision_height(cstate) - if trusted <= 0: - log(f"[warn] could not parse trusted height (got {trusted}) on host={host_chain} client={client_id}") - - start = trusted + 1 - if start > end_height: - log(f"[info] host={host_chain} client={client_id}: already >= end ({trusted} >= {end_height}); skipping") - return - - log(f"[info] host={host_chain} client={client_id}: backfill {start}..{end_height}") - failures = 0 - for h in range(start, end_height + 1): - try: - update_client(host_chain, client_id, height=h) - except subprocess.CalledProcessError as e: - failures += 1 - log(f"[error] update --height {h} failed for host={host_chain} client={client_id}: {e.stderr or e.output}") - continue - except Exception as e: - failures += 1 - log(f"[error] update --height {h} failed for host={host_chain} client={client_id}: {e}") - continue - - if failures == 0: - log(f"[ok] host={host_chain} client={client_id}: backfill complete") - else: - log(f"[warn] host={host_chain} client={client_id}: backfill complete with {failures} failures") - - -def main() -> None: - ap = argparse.ArgumentParser( - description="Backfill IBC UpdateClient from trusted+1 to migrationHeight+window for all connected channels' counterparties using Hermes." - ) - ap.add_argument("chains", nargs="+", help="Chain IDs to scan (e.g., gm-a gm-b gm-c)") - ap.add_argument("--migration-height", type=int, required=True, help="Block height where smoothing starts") - ap.add_argument("--window", type=int, default=30, help="Smoothing window length (default: 30)") - ap.add_argument("--end-height", type=int, default=None, help="Override: backfill up to this height instead of migration_height+window") - args = ap.parse_args() - - end_height = args.end_height if args.end_height is not None else (args.migration_height + args.window) - if end_height <= 0: - log("[error] end height must be positive") - sys.exit(2) - - triples = discover_host_clients(args.chains) - if not triples: - log("[warn] no host clients discovered; nothing to do") - return - - for host_chain, client_id, _subject_chain in triples: - backfill_client(host_chain, client_id, end_height) - - log("[done] backfill attempted for all discovered host clients") - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - log("[warn] interrupted") - sys.exit(130) - diff --git a/scripts/update_ibc_clients_backfill.py b/scripts/update_ibc_clients_backfill.py deleted file mode 100755 index d18964ed..00000000 --- a/scripts/update_ibc_clients_backfill.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import subprocess -import sys -import logging -from typing import Dict, List, Tuple, Set - -logger = logging.getLogger("update_ibc_clients") - - -def _log(level: int, msg: str) -> None: - logger.log(level, msg) - -def _run_hermes(args: List[str], timeout: float = 60.0) -> Dict: - cmd = ["hermes", "--json"] + args - res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) - if res.returncode != 0: - err = (res.stderr or res.stdout or "").strip() - raise subprocess.CalledProcessError(res.returncode, cmd, output=res.stdout, stderr=err) - try: - return json.loads(res.stdout) - except json.JSONDecodeError as e: - raise RuntimeError( - f"failed to decode hermes JSON for {cmd}: {e}\nstdout:\n{res.stdout}\nstderr:\n{res.stderr}" -) - -def _query_channels(chain: str) -> List[dict]: - data = _run_hermes(["query", "channels", "--chain", chain], timeout=90.0) - result = data.get("result", []) - return result if isinstance(result, list) else [] - -def _query_connection_end(chain: str, connection: str) -> Dict: - return _run_hermes(["query", "connection", "end", "--chain", chain, "--connection", connection]) - -def _extract_client_ids(conn_end: Dict) -> Tuple[str, str]: - # Returns (client_id_on_A, client_id_on_B) (B is counterparty's client on host chain) - conn = conn_end.get("result", {}).get("connection", {}) - end = conn.get("end", {}) - client_a = end.get("client_id") or conn.get("client_id") or "" - counterparty = end.get("counterparty") or conn.get("counterparty") or {} - client_b = counterparty.get("client_id") or "" - return client_a, client_b - -def _query_client_state(chain_id: str, client_id: str) -> Dict: - return _run_hermes(["query", "client", "state", "--chain", chain_id, "--client", client_id]) - -def _extract_chain_id(client_state: Dict) -> str: - # Hermes variants: client_state or ClientState - result = client_state.get("result") or [] - if not isinstance(result, list) or not result: - return "" - entry = result[0] - cs = entry.get("client_state") or entry.get("ClientState") or {} - return cs.get("chain_id") or "" - -def _extract_revision_height(client_state: Dict) -> int: - # Parse latest_height.revision_height as int - result = client_state.get("result", []) - if not isinstance(result, list) or not result: - return 0 - entry = result[0] - client_state = entry.get("client_state") or entry.get("ClientState") or {} - latest_height = client_state.get("latest_height") or {} - revision_height = latest_height.get("revision_height") - if isinstance(revision_height, int): - return revision_height - if isinstance(revision_height, str) and revision_height.isdigit(): - return int(revision_height) - try: - return int(str(revision_height)) - except Exception: - _log(logging.WARN, f"failed to parse latest_height.revision_height: {revision_height}") - return 0 - -def _update_client(host_chain: str, client_id: str, height: int = None) -> None: - args = ["update", "client", "--host-chain", host_chain, "--client", client_id] - if height is not None: - args += ["--height", str(height)] - _run_hermes(args, timeout=120.0) - -def _discover_host_clients(chains: List[str]) -> List[Tuple[str, str, str]]: - """ - Returns unique triples: (host_chain, client_id_on_host, subject_chain_id). - host_chain = where the client lives (counterparty), subject_chain_id = chain tracked by client. - """ - seen: Set[Tuple[str, str]] = set() - triples: List[Tuple[str, str, str]] = [] - - for chain in chains: - _log(logging.INFO, f"scanning channels on {chain}") - try: - channels = _query_channels(chain) - except Exception as e: - _log(logging.WARN, f"failed to query channels on {chain}: {e}") - continue - - for idx, channel in enumerate(channels): - hops = channel.get("connection_hops") or [] - port = channel.get("port_id") or "" - chan = channel.get("channel_id") or "" - if not hops or not port or not chan: - _log(logging.WARN, f"{chain} channel[{idx}] missing connection/port/channel, skipping") - continue - - conn_a = hops[0] - _log(logging.INFO, f"{chain} {port}/{chan} via {conn_a}") - - try: - conn_end = _query_connection_end(chain, conn_a) - except Exception as e: - _log(logging.WARN, f"failed to query connection end {conn_a} on {chain}: {e}") - continue - - client_id_a, client_id_b = _extract_client_ids(conn_end) - if not client_id_a or not client_id_b: - _log(logging.WARN, f"missing client IDs for {chain}/{conn_a}; skipping") - continue - - try: - client_state = _query_client_state(chain, client_id_a) - except Exception as e: - _log(logging.WARN, f"failed to query client state {client_id_a} on {chain}: {e}") - continue - - subject_chain = _extract_chain_id(client_state) - if not subject_chain: - _log(logging.WARN, f"could not determine counterparty chain-id for {chain}/{conn_a} (client {client_id_a}); skipping") - continue - - host_chain = subject_chain # update on the counterparty - key = (host_chain, client_id_b) - if key in seen: - continue - seen.add(key) - triples.append((host_chain, client_id_b, subject_chain)) - _log(logging.INFO, f"discovered host={host_chain} client={client_id_b} (tracks {subject_chain})") - - return triples - -def _backfill_client(host_chain: str, client_id: str, end_height: int) -> None: - # Query trusted height on the host client - try: - client_state = _query_client_state(host_chain, client_id) - except Exception as e: - _log(logging.ERROR, f"query client state failed on host={host_chain} client={client_id}: {e}") - return - - trusted_height = _extract_revision_height(client_state) - if trusted_height <= 0: - _log(logging.WARN, f"could not parse trusted height (got {trusted_height}) on host={host_chain} client={client_id}") - - start = trusted_height + 1 - if start > end_height: - _log(logging.INFO, f"host={host_chain} client={client_id}: already >= end ({trusted_height} >= {end_height}). skipping") - return - - _log(logging.INFO, f"host={host_chain} client={client_id}: backfill {start}..{end_height}") - failures = 0 - for h in range(start, end_height + 1): - try: - _update_client(host_chain, client_id, height=h) - except subprocess.CalledProcessError as e: - failures += 1 - _log(logging.ERROR, f"update --height {h} failed for host={host_chain} client={client_id}: {e.stderr or e.output}") - continue - except Exception as e: - failures += 1 - _log(logging.ERROR, f"update --height {h} failed for host={host_chain} client={client_id}: {e}") - continue - - if failures == 0: - _log(logging.INFO, f"host={host_chain} client={client_id}: backfill complete") - else: - _log(logging.WARN, f"host={host_chain} client={client_id}: backfill complete with {failures} failures") - -def main() -> None: - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - ) - - ap = argparse.ArgumentParser( - description="Backfill IBC UpdateClient from trusted+1 to migrationHeight+window for all connected channels' counterparties using Hermes." - ) - ap.add_argument("chains", nargs="+", help="Chain IDs to scan (e.g., gm-1 gm-2)") - ap.add_argument("--migration-height", type=int, required=True, help="Block height where smoothing starts") - ap.add_argument("--window", type=int, default=30, help="Smoothing window length (default: 30)") - args = ap.parse_args() - - end_height = args.migration_height + args.window - if end_height <= 0: - _log(logging.ERROR, "end height must be positive") - sys.exit(2) - - triples = _discover_host_clients(args.chains) - if not triples: - _log(logging.WARN, "no host clients discovered; nothing to do") - return - - for host_chain, client_id, _subject_chain in triples: - _backfill_client(host_chain, client_id, end_height) - - _log(logging.INFO, "backfill attempted for all discovered host clients") - -if __name__ == "__main__": - main() diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 99c9cf9f..24000e7b 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -38,8 +38,6 @@ import ( ) const ( - // matches the variable of the same name in ev-abci. - IBCSmoothingFactor = 30 // firstClientID is the name of the first client that is generated. NOTE: for this test it is always the same // as only a single client is being created on each chain. firstClientID = "07-tendermint-1" @@ -61,13 +59,6 @@ type SingleValidatorSuite struct { preMigrationIBCBal sdk.Coin migrationHeight uint64 - // number of validators on the primary chain at test start - initialValidators int - - // last height on the primary chain (subject) for which we've - // successfully attempted a client update on the counterparty (host). - // Used to step updates height-by-height during migration. - lastUpdatedChainOnCounterparty int64 } func TestSingleValSuite(t *testing.T) { @@ -137,13 +128,11 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.waitForMigrationCompletion(ctx) }) - // Ensure the light client on the counterparty has consensus states for - // every height across the migration window. This can be done AFTER the - // migration by backfilling one height at a time. - // The equivalent of this needs to be done for each counterparty. - t.Run("backfill_client_updates", func(t *testing.T) { - end := int64(s.migrationHeight + IBCSmoothingFactor) - err := s.backfillChainClientOnCounterpartyUntil(ctx, end) + // Perform client update at H + 1 to ensure the counterparty's light client can verify the new validator set. + // At H, the val updates are returned in EndBlock, by specifically sending a client update at H + 1, we ensure that + // subsequent ibc messages will succeed. + t.Run("client_updates_at_upgrade", func(t *testing.T) { + err := updateClientAtHeight(ctx, s.hermes, s.counterpartyChain, firstClientID, int64(s.migrationHeight+1)) s.Require().NoError(err) }) @@ -317,8 +306,11 @@ func (s *SingleValidatorSuite) submitSingleValidatorMigrationProposal(ctx contex curHeight, err := s.chain.Height(ctx) s.Require().NoError(err) - // schedule migration 30 blocks in the future to allow governance - migrateAt := uint64(curHeight + IBCSmoothingFactor) + // schedule migration some blocks in the future to allow governance to pass + // keep this small to speed up the test + // allow enough blocks for deposit and voting to complete + const governanceBuffer = 30 + migrateAt := uint64(curHeight + governanceBuffer) s.migrationHeight = migrateAt s.T().Logf("Current height: %d, Migration at: %d", curHeight, migrateAt) @@ -383,12 +375,13 @@ func (s *SingleValidatorSuite) getValidatorPubKey(ctx context.Context, conn *grp return nil } -// waitForMigrationCompletion waits for the 30-block migration window to complete +// waitForMigrationCompletion waits for the migration to finalize on-chain func (s *SingleValidatorSuite) waitForMigrationCompletion(ctx context.Context) { s.T().Log("Waiting for migration to complete...") - // migration should complete at migrationHeight + IBCSmoothingFactor (30 blocks) - targetHeight := int64(s.migrationHeight + IBCSmoothingFactor) + // migration updates are emitted at migrationHeight, and take effect at migrationHeight+1. + // wait until at least migrationHeight+2 to ensure finalization has occurred. + targetHeight := int64(s.migrationHeight + 2) err := wait.ForCondition(ctx, time.Hour, 10*time.Second, func() (bool, error) { h, err := s.chain.Height(ctx) @@ -636,37 +629,3 @@ func updateClientAtHeight(ctx context.Context, hermes *relayer.Hermes, host *cos _, _, err := hermes.Exec(ctx, hermes.Logger, cmd, nil) return err } - -// backfillChainClientOnCounterpartyUntil steps from the host client's current -// trusted height + 1 up to and including endHeight on the subject chain. -func (s *SingleValidatorSuite) backfillChainClientOnCounterpartyUntil(ctx context.Context, endHeight int64) error { - if s.chain == nil || s.counterpartyChain == nil || s.hermes == nil { - return fmt.Errorf("missing chain(s) or hermes") - } - - // Start from host client's current trusted height + 1 to ensure continuity. - trusted, err := queryClientRevisionHeight(ctx, s.counterpartyChain, firstClientID) - if err != nil { - return err - } - - // Always start from the client's current trusted height + 1 on the host chain - startHeight := trusted + 1 - if startHeight < 1 { - startHeight = 1 - } - - // Do not go past the requested endHeight - if endHeight < startHeight { - return nil - } - - for h := startHeight; h <= endHeight; h++ { - if err := updateClientAtHeight(ctx, s.hermes, s.counterpartyChain, firstClientID, h); err != nil { - s.T().Logf("backfill update at height %d failed: %v", h, err) - return err - } - s.lastUpdatedChainOnCounterparty = h - } - return nil -} From c40550724e57903339bae48887bb7fd78af93b59 Mon Sep 17 00:00:00 2001 From: chatton Date: Fri, 28 Nov 2025 10:37:43 +0000 Subject: [PATCH 4/4] chore: remove validators after migration --- .../single_validator_comet_migration_test.go | 62 +++++++------------ 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/tests/integration/single_validator_comet_migration_test.go b/tests/integration/single_validator_comet_migration_test.go index 24000e7b..0b273033 100644 --- a/tests/integration/single_validator_comet_migration_test.go +++ b/tests/integration/single_validator_comet_migration_test.go @@ -3,7 +3,6 @@ package integration_test import ( "bytes" "context" - "encoding/json" "fmt" "sync" "testing" @@ -140,6 +139,10 @@ func (s *SingleValidatorSuite) TestNTo1StayOnCometMigration() { s.validateSingleValidatorSet(ctx) }) + t.Run("remove_old_validators", func(t *testing.T) { + s.removeOldValidators(ctx) + }) + t.Run("validate_chain_continues", func(t *testing.T) { s.validateChainProducesBlocks(ctx) }) @@ -470,6 +473,25 @@ func (s *SingleValidatorSuite) validateSingleValidatorSet(ctx context.Context) { s.T().Log("Validator set validated: staking has 0 bonded, CometBFT has 1 validator with power=1") } +// removeOldValidators removes all validator containers except the first one +func (s *SingleValidatorSuite) removeOldValidators(ctx context.Context) { + s.T().Log("Removing old validator containers...") + + nodes := s.chain.GetNodes() + s.Require().Greater(len(nodes), 1, "expected multiple validators to remove") + + // remove all validators except the first one (index 0) + for i := 1; i < len(nodes); i++ { + node := nodes[i].(*cosmos.ChainNode) + s.T().Logf("Stopping and removing node %d", i) + + err := node.Remove(ctx) + s.Require().NoError(err, "failed to remove container for node %d", i) + } + + s.T().Logf("Removed %d old validator containers", len(nodes)-1) +} + // validateChainProducesBlocks validates the chain continues to produce blocks func (s *SingleValidatorSuite) validateChainProducesBlocks(ctx context.Context) { s.T().Log("Validating chain produces blocks...") @@ -477,7 +499,7 @@ func (s *SingleValidatorSuite) validateChainProducesBlocks(ctx context.Context) initialHeight, err := s.chain.Height(ctx) s.Require().NoError(err) - err = wait.ForBlocks(ctx, 5, s.chain) + err = wait.ForBlocks(ctx, 10, s.chain) s.Require().NoError(err) finalHeight, err := s.chain.Height(ctx) @@ -579,42 +601,6 @@ func (s *SingleValidatorSuite) calculateIBCDenom(portID, channelID, baseDenom st return transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom() } -// queryClientRevisionHeight returns latest_height.revision_height for the client on the host chain. -func queryClientRevisionHeight(ctx context.Context, host *cosmos.Chain, clientID string) (int64, error) { - nodes := host.GetNodes() - if len(nodes) == 0 { - return 0, fmt.Errorf("no nodes for host chain") - } - node := nodes[0].(*cosmos.ChainNode) - - networkInfo, err := node.GetNetworkInfo(ctx) - if err != nil { - return 0, fmt.Errorf("failed to get host node network info: %w", err) - } - - stdout, stderr, err := node.Exec(ctx, []string{ - "gmd", "q", "ibc", "client", "state", clientID, "-o", "json", - "--grpc-addr", networkInfo.Internal.GRPCAddress(), "--grpc-insecure", "--prove=false", - }, nil) - if err != nil { - return 0, fmt.Errorf("query client state failed: %s", stderr) - } - var resp struct { - ClientState struct { - LatestHeight struct { - RevisionHeight json.Number `json:"revision_height"` - } `json:"latest_height"` - } `json:"client_state"` - } - if err := json.Unmarshal(stdout, &resp); err != nil { - return 0, fmt.Errorf("failed to decode client state JSON: %w", err) - } - if rh, err := resp.ClientState.LatestHeight.RevisionHeight.Int64(); err == nil { - return rh, nil - } - return 0, fmt.Errorf("could not parse client revision_height from host state JSON") -} - // updateClientAtHeight updates the client by submitting a header for a specific // subject-chain height. Hermes expects a numeric height; for single-revision // test chains this is sufficient.