CometBFT v0.38.25 can’t read an ML-DSA public key. Its PubKeyFromProto knows Ed25519 and secp256k1, and its MaxSignatureSize is 64 bytes, while an ML-DSA-65 signature is 3,309. That’s the version Gaia v29.0.1 runs, and the v29.1.0 release candidate from Monday keeps it.
The Quarterly says post-quantum consensus keys shipped with SDK v0.55 and CometBFT v0.40, and that the Hub moves to v30 once ibc-go carries v0.55. Before any chain lets its validators use those keys, I wanted to know what breaks on the other side of its IBC connections, so I read the rotation code and the light clients. The SDK docs already say every counterparty must upgrade before the first validator rotates. This is what I’d add to that.
v0.38.26 is enough for a Go counterparty
The docs ask counterparties for CometBFT v0.40. On the 0.38 line there’s a smaller step. v0.38.26 (13 August, PR 6028) backports only ML-DSA decoding and verification, “for the purposes of verification of IBC connections with counterparty chains that are on v0.40”, and raises MaxSignatureSize to 3,309. It doesn’t let the chain itself use the key. So a chain on SDK v0.53 doesn’t need the v0.55 migration for its own clients of an ML-DSA chain, a patch release on the line it’s on covers it.
What a counterparty runs is in its /cosmos/base/tendermint/v1beta1/node_info, under build_deps.
Not every verifier is Go
tendermint-rs v0.41.0, released 26 October, still has two key types, Ed25519 and secp256k1. Hermes is built on it, so is ibc-rs, and so are the Tendermint light clients in cosmos/ibc-contracts, the SP1 programs for the EVM side and the Solana one. So “every counterparty” also means relayers and light clients on chains that don’t run CometBFT at all.
Whoever adds it has to match CometBFT’s signer exactly: ML-DSA-65 from FIPS 204, pure mode, empty context, as the top of crypto/mldsa65/key.go says. For Ed25519 CometBFT had to pin ZIP 215 because libraries disagreed on edge cases. FIPS 204 leaves much less room there, but a verifier that uses HashML-DSA or puts anything in the context rejects every vote.
Nothing on chain checks the order
RotateConsPubKey in SDK v0.55.0 checks the new key type against the chain’s own pub_key_types (msg_server.go) and nothing about counterparties. So the real switch is the governance proposal that adds ml_dsa_65 to the consensus params. Once it passes, any one validator decides for every client of the chain.
And it can’t take it back quickly. A validator gets one rotation per unbonding period, and ibc-go refuses a client whose trusting period isn’t shorter than the unbonding period (the Hub’s client of Akash uses 14 days against 21). So a client that stops updating at the rotation expires before that validator is even allowed to rotate back. After that it’s a governance MsgRecoverClient on each counterparty.
Updates get heavy
I built a header with CometBFT v0.40.0’s own types for 180 validators, the Hub’s set size. A 07-tendermint update carries the signed header plus the validator set twice (the new set and the trusted one), and that comes to 46,191 bytes with Ed25519 and 1,326,777 with every key on ML-DSA. It’s over the 1 MB default max_tx_bytes of v0.38 nodes (v0.40 raised the default to 4 MB). On a 2 MB block like the Hub’s it’s two thirds of a block, and at the Hub’s 10 gas per byte about 13 million gas before a single signature is checked.
So before a chain adds ml_dsa_65 I’d list every client of it, the chains in its channel list but also every relayer and every light client outside Cosmos, check that each one decodes the key, and do one real rotation on a testnet whose counterparties run the versions you see in production.
Teams thinking about allowing ml_dsa_65: how do you find every light client of your chain today, including the ones on chains that don’t run CometBFT?