feat: bind consumer and provider IBC clients by explicit owner declaration - #65
Open
giunatale wants to merge 3 commits into
Open
Conversation
giunatale
requested review from
clockworkgr,
julienrbrt and
tbruyelle
as code owners
July 30, 2026 15:54
Contributor
Author
|
Branched from |
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
July 31, 2026 18:40
68f0322 to
231ec34
Compare
giunatale
force-pushed
the
giunatale/feat/client-authentication
branch
from
July 31, 2026 18:40
2b71a23 to
92ea64b
Compare
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
July 31, 2026 19:56
231ec34 to
61c2660
Compare
giunatale
force-pushed
the
giunatale/feat/client-authentication
branch
from
July 31, 2026 19:56
92ea64b to
021c4bd
Compare
julienrbrt
reviewed
Aug 6, 2026
tbruyelle
requested changes
Aug 15, 2026
tbruyelle
left a comment
Contributor
There was a problem hiding this comment.
The PR introduces a clear improvement of the client id healing process, but there's still possibility to front-run the client id pin, with the consequences of having to restart the consumer bootstrap.
Added 2 comments, one for consumer and one for provider client id healing, both avocate for an extra manual step instead of the self-healing.
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
August 25, 2026 16:13
61c2660 to
afd8748
Compare
giunatale
force-pushed
the
giunatale/feat/client-authentication
branch
from
August 25, 2026 16:21
021c4bd to
295c3ae
Compare
tbruyelle
approved these changes
Aug 27, 2026
Both sides previously trusted the chain-id string alone: the provider re-ran client discovery whenever its adopted client expired, frozen, or lost its counterparty, adopting any Active same-chain-id client; the consumer re-pinned ProviderClientID to whatever same-chain-id client the latest VSC packet arrived over. Anyone can permissionlessly create an IBC v2 client of a chain that reuses a chain-id string and have a relayer route packets over it, so either side could be captured by a look-alike chain. Provider: discovery now content-verifies every candidate before adoption. A candidate must be an Active tendermint client of the consumer's chain id with a registered counterparty whose latest consensus state carries the CometBFT hash of the validator set the provider itself most recently computed for that consumer (built from the stored per-consumer set, i.e. the assigned consumer keys), or the hash of the set before it: the consumer keeps running the previous set while the newest VSC packet is in flight, so both hashes are honest. The previous set's hash is retained when SetConsumerValSet rotates the stored set (new ConsumerPrevValSetHash collection, exported and restored via the new prev_consumer_valset_hash field on ConsumerState). A chain-id match that fails the content check is logged at warn level and skipped. If no candidate verifies, nothing is adopted and discovery retries next epoch: fail closed, with the liveness sweep owning removal of a consumer that never gets served. Once adopted, the client is returned unconditionally forever; expiry or freezing halts traffic instead of reopening adoption. Consumer: the ProviderClientID heal is gone. The pin is established at genesis and moves at most once, from the genesis-created client, which can never carry packets (a client created outside a MsgCreateClient has no recorded creator, so nobody can register the IBC v2 counterparty packet routing requires), to the first client that actually delivers a VSC packet. From then on any VSC arriving over a different client is rejected before any state change, with the chain-id gate kept in front as defense in depth; a missing pin also rejects, since both genesis paths establish one. Neither binding needs a vaas-level escape hatch: a dead client is revived in place by ibc-go's governance-gated MsgRecoverClient, which substitutes fresh client state under the same client id, so both the provider latch and the consumer pin survive recovery unchanged. Acknowledgements and timeouts proven for a client no consumer tracks (packets sent before their consumer was removed) are now log-only instead of erroring, so a relayer's tx is not failed over an honest stale delivery. Tests: provider discovery coverage for the forged-client rejection and its warn log, content-verified adoption, one-step tolerance, the latch holding across client death with no re-discovery, fail-closed retry, highest-height tie-break, the prev-hash rotation, the CometBFT hash equivalence (computed independently of the production helper), and the genesis round-trip of the new field; consumer coverage for the bootstrap adoption plus permanent latch, rejection of non-pinned clients before any state change, and the missing-pin rejection; the unknown-client ack and timeout tests now assert the log-only behavior. Existing tests that encoded the heal or the auto-switch were updated to assert the new semantics, and OnRecv tests now pre-pin the provider client the way genesis always does.
Review response: the content-bound discovery this branch shipped was proven unsound in both directions, so the mechanism is replaced rather than patched. Inferring the binding was broken two ways. A creation-time consensus state is stored unverified, so an attacker's client can carry the provider's own consumer-valset hash (public state) with an absurd latest height and win any content comparison; and an honest, not-yet-bound consumer falls out of the compared hash window after two epochs, because the provider rotates its stored consumer valset every epoch whether or not anything was delivered, after which it is rejected forever and the liveness sweep deletes it. The valset-hash machinery, the previous-valset-hash state, and its proto field are removed. On the provider, MsgUpdateConsumer gains an optional client_id the owner may set exactly once, once the consumer is launched and a relayer has created the client. The declared client must be an Active tendermint client of the consumer's chain id with a registered IBC v2 counterparty and a trusting period strictly above the downtime challenge horizon (evidence max age plus challenge window), so every accepted downtime accusation stays disprovable for its whole challenge window. Registration now also refuses a consumer whose unbonding period cannot support that horizon at all, since a light client's trusting period must sit below its chain's unbonding period. On the consumer, genesis no longer creates a client. The self-created client could never carry packets -- with no recorded creator its IBC v2 counterparty could never be registered -- and its existence forced the pin to be movable at bootstrap, which is exactly what made the bootstrap front-runnable. A new chain starts with no pin and rejects every VSC packet until the owner the provider seeded into the consumer params (or governance) pins the relayer-created client via the new MsgSetProviderClient, validated the same way. Owner authorization compares decoded address bytes, since the provider renders the owner under its own bech32 prefix. The pre-pin message filter admits /ibc.*, the pin message itself, and /cosmos.gov.*, since an /ibc.*-only filter can never admit the message that establishes the pin. A consumer that restarts before being pinned round-trips its genesis instead of collapsing to a default one. Both pins are permanent, because client ids are identity: the provider keys downtime state by its client ids and the consumer's photon fee denom derives from its pin. A dead client is recovered in place by governance via ibc-go's MsgRecoverClient, which substitutes fresh client state under the same id. The e2e and localnet flows register consumers from a dedicated owner key (HD index 1 of the shared mnemonic, present on both chains; the relayer key's sequence is consumed concurrently by the relayer itself) and declare both clients after the relayer creates them.
ibc-go's clientv2 GenesisState.Validate (v10.2 through at least v10.7) rejects a counterparty entry whose counterparty client id equals the local client id. Those identifiers live in different chains' namespaces, and equality is the normal case: two fresh chains both name their first client 07-tendermint-0. ibc-go's own MsgRegisterCounterparty accepts exactly that pairing, so the state is created at runtime but cannot be re-imported, and any restart from an exported genesis panics at InitChain. With no consumer genesis client offsetting the id counter, every provider or consumer restart hits it. Both apps now wire app/ibcshim, which mirrors ibc core's InitGenesis submodule by submodule and inlines clientv2's checks minus that one comparison. A sentinel test asserts upstream still rejects the input, so the package is deleted the moment the fix lands upstream.
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
August 27, 2026 13:39
afd8748 to
2af16a3
Compare
giunatale
force-pushed
the
giunatale/feat/client-authentication
branch
from
August 27, 2026 13:39
ea437e0 to
f45483a
Compare
julienrbrt
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review follow-up (713ed6b, on top of the reviewed commit): client discovery and
first-packet adoption are replaced by explicit, owner-signed, once-only
declarations on both chains.
Why
Inferring the binding was broken both ways. A creation-time consensus state is
stored unverified, so a forged client carrying the provider's own (public)
consumer-valset hash wins any content comparison. And an honest consumer that
is not bound within two epochs falls out of the compared hash window, because
the provider rotates its stored valset every epoch, and is eventually deleted
by the liveness sweep.
How it works now
client_idonMsgUpdateConsumer,exactly once. The client must exist, be an Active tendermint client of the
consumer's chain id, have a registered IBC v2 counterparty, and have a
trusting period above the downtime challenge horizon (evidence max age plus
challenge window), so accepted accusations stay disprovable. Registration
rejects an unbonding period at or below that horizon. Until declared, VSC
packets stay queued.
new chain rejects every VSC packet until the owner (a new
owner_addressconsumer param seeded by the provider; governance always may) pins the
relayer-created client with the new
MsgSetProviderClient, validated thesame way. Owner matching compares address bytes across bech32 prefixes. The
pre-pin message filter admits
/ibc.*, the pin message, and/cosmos.gov.*.fee denom), so a dead client is recovered in place via governance
MsgRecoverClient. Known residual: a malicious owner can declare a client ofa chain they control; forged downtime evidence from it is answered by the
challenge mechanism.
of exporting a default one.
Upstream ibc-go issue found by the restart e2e
clientv2's genesis
Validaterejectsclient_id == counterparty.client_id,but those ids are from different chains' namespaces and collide naturally (two
fresh chains both name their first client
07-tendermint-0). ibc-go's ownMsgRegisterCounterpartyaccepts the pairing, so exported state cannot bere-imported: any restart from export panics at InitChain. Present in v10.2
through v10.7; needs an upstream report. ea437e0 wires
app/ibcshiminto bothapps as a workaround: it mirrors ibc core InitGenesis and skips only that
check, with a sentinel test that tells us when upstream fixes it.
Operating
Register the consumer from an account whose address bytes exist on both chains
(e2e/localnet derive an
ownerkey at HD index 1 of the shared mnemonic), thenafter the relayer creates the clients:
tx provider update-consumerwithclient_id, andtx vaasconsumer set-provider-client. The e2e harness andmake localnet-declare-clientsdo both.Testing
Every declaration and pin rejection is unit-tested and mutation-checked; the
registration floor, owner seeding, unpinned restart round-trip, and pre-pin
filter each have dedicated tests. The liveness e2e clock was rescaled (400s
unbonding, 60s/120s downtime windows) to stay coherent with the new trusting
check. Liveness suite 5/5; main suite green with the ibcshim workaround.