Relay IBC packets with Hermes

Written by Furi13 min read

On this page

This guide is for node operators who want to relay IBC packets between Cosmos Hub and Osmosis. At the end, Hermes relays on the existing transfer channel with its own fee wallets, and you can read its metrics and clear stuck packets.

Prerequisites#

  • A Linux or macOS machine on x86_64 or aarch64. Hermes v1.13.3 ships release binaries for these four targets only.
  • Hardware sized for any full nodes you run on the same machine. Hermes does not publish minimum specs, and the relayer itself is light.
  • A synced system clock. Hermes allows 5 seconds of clock drift by default, so run an NTP client such as chrony.
  • curl, jq and tar. A source build also needs git and Rust 1.85.0 or newer from rustup (opens in a new tab).
  • RPC, gRPC and REST endpoints for both chains. Your own nodes are the best choice. Each node needs gRPC on, with enable = true under [grpc] in app.toml. It also needs tx indexing, with indexer = "kv" under [tx_index] in CometBFT's config.toml, because Hermes reads packet data from the tx index.
  • A dedicated relayer wallet with a small ATOM and OSMO balance for fees. Never reuse a validator key or a cold wallet.

The commands and config in this guide use these values from the Cosmos chain registry (opens in a new tab). On October 22, 2026, both channel ends reported STATE_OPEN on chain.

ItemCosmos HubOsmosis
Chain IDcosmoshub-4osmosis-1
Address prefixcosmososmo
Fee denomuatomuosmo
Transfer channelchannel-141channel-0
Connectionconnection-257connection-1

1. Install Hermes#

The latest release is v1.13.3 (opens in a new tab). Its only change is a fix to the release build, which now publishes Linux ARM binaries. The code and crate version are still 1.13.2, so hermes version prints hermes 1.13.2.

Install from a release binary or build from source.

Use a release binary#

Set the version and your target. Valid targets are x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin and aarch64-apple-darwin.

bash
HERMES_VERSION=v1.13.3
TARGET=x86_64-unknown-linux-gnu
ARCHIVE=hermes-${HERMES_VERSION}-${TARGET}.tar.gz
curl -LO https://github.com/informalsystems/hermes/releases/download/${HERMES_VERSION}/${ARCHIVE}

The release page lists a sha256 digest next to each archive, and the release has no separate checksum file. These commands read the digest from the GitHub API and check the download against it:

bash
EXPECTED=$(curl -s https://api.github.com/repos/informalsystems/hermes/releases/tags/${HERMES_VERSION} \
  | jq -r --arg n "$ARCHIVE" '.assets[] | select(.name == $n) | .digest' | sed 's/^sha256://')
echo "${EXPECTED}  ${ARCHIVE}" | sha256sum -c -

On macOS, replace sha256sum -c - with shasum -a 256 -c -. The check must print OK. If it fails, stop. If GitHub rate limits the API request, compare against the digest on the release page by hand.

Extract the binary and add it to your PATH. On zsh, write to $HOME/.zshrc instead of $HOME/.bashrc.

bash
mkdir -p $HOME/.hermes/bin
tar -C $HOME/.hermes/bin/ -vxzf "$ARCHIVE"
echo 'export PATH="$HOME/.hermes/bin:$PATH"' >> $HOME/.bashrc
source $HOME/.bashrc
hermes version

On macOS, Gatekeeper may block the first run. Allow it under Privacy & Security in System Settings.

Build from source#

The repository pins Rust 1.85.0 in rust-toolchain.toml, and rustup installs that toolchain on the first build.

bash
git clone https://github.com/informalsystems/hermes.git
cd hermes
git checkout v1.13.3
cargo build --release --bin hermes --locked
mkdir -p $HOME/.hermes/bin
cp target/release/hermes $HOME/.hermes/bin/

2. Write the config file#

Hermes reads $HOME/.hermes/config.toml by default. The global --config <path> flag points it at another file.

Pick and test endpoints#

Open cosmoshub/chain.json and osmosis/chain.json in the chain registry (opens in a new tab). Pick endpoints from apis.rpc, apis.grpc and apis.rest. Public endpoints change often, so test each RPC first:

bash
curl -s https://<hub-rpc-host>/status | jq -r '.result.node_info.network, .result.node_info.other.tx_index'

It should print cosmoshub-4 and on. Repeat with <osmo-rpc-host> and expect osmosis-1 and on.

The config below uses these placeholders:

  • <hub-rpc-host> and <osmo-rpc-host> are the RPC hosts, plus a port or path if the provider uses one, for example rpc.example.com:443.
  • <hub-grpc-host> and <osmo-grpc-host> are the gRPC hosts and ports, such as grpc.example.com:443. Most registry gRPC entries have no scheme. Hermes needs a URL, so add https:// for TLS endpoints.
  • <hub-rest-host> and <osmo-rest-host> are the REST (LCD) hosts.
  • <your-moniker> is your relayer or validator name. Hermes puts it at the start of every transaction memo.

Example config.toml#

toml
1[global]
2log_level = 'info'
3
4[mode.clients]
5enabled = true
6refresh = true
7misbehaviour = true
8
9[mode.connections]
10enabled = false
11
12[mode.channels]
13enabled = false
14
15[mode.packets]
16enabled = true
17clear_interval = 100
18clear_on_start = true
19tx_confirmation = false
20
21[telemetry]
22enabled = true
23host = '127.0.0.1'
24port = 3001
25
26[[chains]]
27id = 'cosmoshub-4'
28type = 'CosmosSdk'
29rpc_addr = 'https://<hub-rpc-host>'
30grpc_addr = 'https://<hub-grpc-host>'
31event_source = { mode = 'push', url = 'wss://<hub-rpc-host>/websocket', batch_delay = '500ms' }
32rpc_timeout = '10s'
33account_prefix = 'cosmos'
34key_name = 'relayer-hub'
35store_prefix = 'ibc'
36default_gas = 100000
37max_gas = 4000000
38gas_multiplier = 1.1
39gas_price = { price = 0.025, denom = 'uatom' }
40dynamic_gas_price = { enabled = true, multiplier = 1.1, max = 0.6 }
41max_msg_num = 30
42max_tx_size = 2097152
43clock_drift = '5s'
44max_block_time = '30s'
45memo_prefix = 'Relayed by <your-moniker>'
46
47[chains.packet_filter]
48policy = 'allow'
49list = [
50  ['transfer', 'channel-141'],
51]
52
53[[chains]]
54id = 'osmosis-1'
55type = 'CosmosSdk'
56rpc_addr = 'https://<osmo-rpc-host>'
57grpc_addr = 'https://<osmo-grpc-host>'
58event_source = { mode = 'pull', interval = '1s', max_retries = 4 }
59rpc_timeout = '10s'
60account_prefix = 'osmo'
61key_name = 'relayer-osmo'
62store_prefix = 'ibc'
63default_gas = 100000
64max_gas = 4000000
65gas_multiplier = 1.1
66gas_price = { price = 0.1, denom = 'uosmo' }
67dynamic_gas_price = { enabled = true, multiplier = 1.1, max = 0.6 }
68max_msg_num = 30
69max_tx_size = 2097152
70clock_drift = '5s'
71max_block_time = '30s'
72memo_prefix = 'Relayed by <your-moniker>'
73
74[chains.packet_filter]
75policy = 'allow'
76list = [
77  ['transfer', 'channel-0'],
78]

What the main settings do:

  • Push mode, the default for event_source, subscribes to the node's WebSocket. Pull mode polls /block_results. The Hermes docs reserve pull mode for chains where the WebSocket misses events, such as CosmWasm chains whose contracts emit IBC events without the message attribute. Osmosis runs CosmWasm, so the example uses pull mode for Osmosis.
  • The gas_price values are the registry's average_gas_price, and each must be at least the node's minimum gas price. With dynamic gas on, Hermes uses gas_price only when the fee market query fails.
  • With dynamic_gas_price, Hermes queries the chain's current base fee, multiplies it by multiplier and caps the result at max. Osmosis exposes an EIP-1559 style base fee, and Cosmos Hub runs Skip's x/feemarket.
  • Hermes simulates each transaction and multiplies the gas estimate by gas_multiplier. It caps gas at max_gas and uses default_gas when simulation fails.
  • packet_filter limits relaying to the listed port and channel. Without it, Hermes relays on every channel between the configured chains.
  • With mode.connections and mode.channels set to false, Hermes relays on existing paths and does not finish handshakes that others start.
  • memo_prefix takes 50 characters at most. Hermes appends its own debug information after it.

Check the current fee market prices before you tune gas_price and max:

bash
curl -s https://<hub-rest-host>/feemarket/v1/gas_price/uatom
curl -s https://<osmo-rest-host>/osmosis/txfees/v1beta1/cur_eip_base_fee

Then validate the file:

bash
hermes config validate

hermes config auto can also generate a starting file from the registry. It takes chain names, not chain IDs, and writes packet filters from the registry's _IBC data. It sets gas parameters to defaults and uses pull mode for every chain, so review the output before you use it.

bash
hermes config auto --output $HOME/.hermes/config-auto.toml --chain cosmoshub:relayer-hub --chain osmosis:relayer-osmo

3. Add relayer keys#

Hermes has no encrypted keyring. It stores keys as plain files in $HOME/.hermes/keys, and each file holds the private key unencrypted. Treat the server as a hot wallet. Keep only fee money in the relayer wallet and store the mnemonic backup offline.

Pass the mnemonic through stdin so it never lands in a file or your shell history. Hermes supports /dev/stdin for this flag on Linux and macOS.

bash
hermes keys add --chain cosmoshub-4 --key-name relayer-hub --mnemonic-file /dev/stdin

Type or paste the mnemonic on one line in your own terminal. Press Enter, then Ctrl+D. Repeat for Osmosis:

bash
hermes keys add --chain osmosis-1 --key-name relayer-osmo --mnemonic-file /dev/stdin

Both chains use coin type 118, and Hermes defaults to the path m/44'/118'/0'/0/0. The same mnemonic gives you a cosmos1... address and an osmo1... address. The --key-name values must match key_name in the config.

Remove group and other access to the key folder, then check the balances:

bash
chmod -R go-rwx $HOME/.hermes/keys
hermes keys list --chain cosmoshub-4
hermes keys balance --chain cosmoshub-4
hermes keys balance --chain osmosis-1

Use each wallet with one Hermes instance only. Hermes tracks the account sequence locally, so a second process signing with the same key causes sequence mismatches.

4. Run a health check#

Run the check against every chain in the config:

bash
hermes health-check

A passing run ends with SUCCESS performed health check for all chains in the config. Hermes also warns here when a chain runs a Cosmos SDK or ibc-go version outside its supported range.

5. Reuse or create a path#

Reuse the existing channel#

The Hermes production tutorial advises against creating a new channel when one with the same port already exists. Cosmos Hub and Osmosis already share a transfer channel. Confirm its state:

bash
hermes query channel end --chain cosmoshub-4 --port transfer --channel channel-141
hermes query connection end --chain cosmoshub-4 --connection connection-257

The channel should be open, with channel-0 on osmosis-1 as its counterparty. For other chain pairs, look in the registry's _IBC folder (opens in a new tab).

Create a path on testnets or new chains#

This command creates a client, a connection and a channel in one step:

bash
hermes create channel --a-chain <chain-a-id> --b-chain <chain-b-id> --a-port transfer --b-port transfer --new-client-connection --yes

If a connection already exists, open a channel on it instead:

bash
hermes create channel --a-chain <chain-a-id> --a-connection <connection-id> --a-port transfer --b-port transfer

<chain-a-id> and <chain-b-id> are chain IDs from your config. <connection-id> is the connection ID on chain A. Add the new channel IDs to each packet_filter and restart Hermes. Unless you set trusting_period, Hermes gives new clients a trusting period of 2/3 of the unbonding period.

6. Start the relayer#

Run Hermes in the foreground first and watch the logs:

bash
hermes start

For production on Linux, run Hermes under systemd. Create /etc/systemd/system/hermes.service, and use which hermes to confirm the binary path.

ini
1[Unit]
2Description=Hermes IBC relayer
3After=network-online.target
4Wants=network-online.target
5
6[Service]
7User=<your-user>
8ExecStart=/home/<your-user>/.hermes/bin/hermes --config /home/<your-user>/.hermes/config.toml start
9Restart=on-failure
10RestartSec=10
11LimitNOFILE=65535
12
13[Install]
14WantedBy=multi-user.target

<your-user> is the Linux account that owns $HOME/.hermes. Enable the service and follow its logs:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now hermes
journalctl -u hermes -f

7. Read telemetry#

With telemetry enabled, Hermes serves Prometheus metrics at http://127.0.0.1:3001/metrics.

bash
curl -s http://127.0.0.1:3001/metrics | grep -E '^(workers|backlog_size|backlog_oldest_sequence|wallet_balance)'
  • workers{type="packet"} at 0 means Hermes is not relaying packets.
  • backlog_size counts SendPacket events that are not yet relayed or timed out.
  • wallet_balance shows the balance of each fee wallet. Set an alert on it so you can top up a wallet before it runs out.

Keep the port bound to 127.0.0.1, or use a firewall so only your Prometheus server can reach it. Since v1.13.2, an optional prefix key in [telemetry] adds a namespace to metric names.

8. Clear pending packets#

With clear_on_start = true and clear_interval = 100, Hermes clears pending packets at startup and every 100 blocks. To inspect a channel by hand:

bash
hermes query packet pending --chain cosmoshub-4 --port transfer --channel channel-141

The output has a forward section and a reverse section, each with unreceived packets and acks. To relay stuck packets:

bash
sudo systemctl stop hermes
hermes clear packets --chain cosmoshub-4 --port transfer --channel channel-141
sudo systemctl start hermes

Without --packet-sequences, the command clears pending packets on both chains. Use --packet-sequences 1,10..20 to target specific sequences. Stopping the service first keeps two processes from signing with one key. To keep the service running, pass a second funded key with --key-name and --counterparty-key-name instead.

Verify it works#

  • hermes health-check passes for both chains.
  • journalctl -u hermes shows no repeating errors.
  • workers{type="packet"} is above 0 in telemetry.
  • hermes query packet pending returns short lists that drain over time. Many relayers serve this channel, so another relayer may deliver a packet before yours does.
  • wallet_balance drops slowly as your relayer pays fees, and your relayer address shows IBC transactions in a block explorer.

Troubleshooting#

insufficient fees#

This error in the log means the fee price was below the node's minimum. Compare the fee market values from step 2 with your config. If the base fee times multiplier is above dynamic_gas_price.max, Hermes pays only max, so raise max. Raise gas_price.price too if the fee market query fails. Restart Hermes after each change.

TxSimulateGasEstimateExceeded or out of gas#

TxSimulateGasEstimateExceeded means the simulated gas itself is above max_gas. An out of gas error means Hermes capped the multiplied estimate at max_gas, or simulation failed and default_gas was too low. Raise max_gas, and raise default_gas if simulation keeps failing. See the Hermes gas errors page (opens in a new tab).

Account sequence mismatch#

This error means another process signs with the same key, or the node's mempool does not recheck transactions. Give each Hermes instance its own wallet. On your own nodes, keep recheck = true under [mempool] in CometBFT's config.toml.

Packets stay pending#

A warning that starts with no packet data was pulled at height means the node no longer has the transactions that hold the packet data. This happens on pruned nodes and on nodes with tx indexing turned off. Point Hermes at a node with tx indexing on and a longer history, and see uncleared pending packets (opens in a new tab) in the Hermes docs. If Hermes misses events on a CosmWasm chain, switch that chain to pull mode.

Next steps#

Keep reading

All guides