Skip to content

feat: bind consumer and provider IBC clients by explicit owner declaration - #65

Open
giunatale wants to merge 3 commits into
giunatale/feat/consensus-key-rotationfrom
giunatale/feat/client-authentication
Open

feat: bind consumer and provider IBC clients by explicit owner declaration#65
giunatale wants to merge 3 commits into
giunatale/feat/consensus-key-rotationfrom
giunatale/feat/client-authentication

Conversation

@giunatale

@giunatale giunatale commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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

  • Provider: the owner sets an optional client_id on MsgUpdateConsumer,
    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.
  • Consumer: genesis creates no client and only seeds the provider chain id. A
    new chain rejects every VSC packet until the owner (a new owner_address
    consumer param seeded by the provider; governance always may) pins the
    relayer-created client with the new MsgSetProviderClient, validated the
    same way. Owner matching compares address bytes across bech32 prefixes. The
    pre-pin message filter admits /ibc.*, the pin message, and /cosmos.gov.*.
  • Both bindings are permanent. Client ids are identity (downtime state, photon
    fee denom), so a dead client is recovered in place via governance
    MsgRecoverClient. Known residual: a malicious owner can declare a client of
    a chain they control; forged downtime evidence from it is answered by the
    challenge mechanism.
  • A consumer restarting before being pinned now round-trips its genesis instead
    of exporting a default one.

Upstream ibc-go issue found by the restart e2e

clientv2's genesis Validate rejects client_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 own
MsgRegisterCounterparty accepts the pairing, so exported state cannot be
re-imported: any restart from export panics at InitChain. Present in v10.2
through v10.7; needs an upstream report. ea437e0 wires app/ibcshim into both
apps 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 owner key at HD index 1 of the shared mnemonic), then
after the relayer creates the clients: tx provider update-consumer with
client_id, and tx vaasconsumer set-provider-client. The e2e harness and
make localnet-declare-clients do 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.

@giunatale

Copy link
Copy Markdown
Contributor Author

Branched from giunatale/feat/consensus-key-rotation (#64) (hard dependency: needs atomone-sdk v0.500.2 and the rotation hooks). Opened against that branch; will retarget to main once it lands.

@giunatale
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from 68f0322 to 231ec34 Compare July 31, 2026 18:40
@giunatale
giunatale force-pushed the giunatale/feat/client-authentication branch from 2b71a23 to 92ea64b Compare July 31, 2026 18:40
@giunatale
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from 231ec34 to 61c2660 Compare July 31, 2026 19:56
@giunatale
giunatale force-pushed the giunatale/feat/client-authentication branch from 92ea64b to 021c4bd Compare July 31, 2026 19:56
Comment thread x/vaas/provider/keeper/relay.go Outdated

@tbruyelle tbruyelle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread x/vaas/provider/keeper/relay.go Outdated
Comment thread x/vaas/consumer/keeper/relay.go
@giunatale
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from 61c2660 to afd8748 Compare August 25, 2026 16:13
@giunatale
giunatale force-pushed the giunatale/feat/client-authentication branch from 021c4bd to 295c3ae Compare August 25, 2026 16:21
@giunatale giunatale changed the title feat: authenticate counterparty clients by content and pin them after adoption feat: bind consumer and provider IBC clients by explicit owner declaration Aug 26, 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
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from afd8748 to 2af16a3 Compare August 27, 2026 13:39
@giunatale
giunatale force-pushed the giunatale/feat/client-authentication branch from ea437e0 to f45483a Compare August 27, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants