feat: support provider consensus-key rotation on atomone-sdk v0.500.2 - #64
Open
giunatale wants to merge 1 commit into
Open
feat: support provider consensus-key rotation on atomone-sdk v0.500.2#64giunatale wants to merge 1 commit into
giunatale wants to merge 1 commit into
Conversation
giunatale
requested review from
clockworkgr,
julienrbrt and
tbruyelle
as code owners
July 30, 2026 15:51
Contributor
Author
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
July 31, 2026 18:40
68f0322 to
231ec34
Compare
atomone's cosmos-sdk v0.500.2 (v0.53-era) adds the consensus-key-rotation feature, so pin the replace to it in both go.mod files (aligning the replace with the v0.53 require the module already declared), implement the interface methods that version requires, and add the guards the new feature needs: - provider Hooks.AfterConsensusPubKeyUpdate: when a validator rotates its provider consensus key, migrate its per-consumer key-assignment state (ValidatorConsumerPubKey and the ValidatorByConsumerAddr reverse mappings) from the old provider consensus address to the new one, so the assigned consumer key keeps resolving in the VSC set computation and cross-chain evidence keeps attributing to the validator. A default (unassigned) validator has no such state; the next epoch's VSC picks up its rotated key. - guard the rotation at tx admission: a new ante decorator in x/vaas/provider/ante rejects a MsgRotateConsPubKey whose declared new consensus key is already assigned as some validator's consumer key -- the SDK's uniqueness check does not see VAAS key assignments and the rotation message carries no proof of possession, so without this a validator could rotate onto another's consumer key and make two validators share a consumer consensus address. The check is shared with the validator-creation guard (IsConsumerConsAddrInUse), reads only committed state so it is deterministic in CheckTx and DeliverTx alike, and unwraps authz MsgExec so a wrapped rotation cannot slip past. It cannot live in the staking hook: x/staking calls that hook from ApplyAndReturnValidatorSetUpdates in EndBlock, after MsgRotateConsPubKey has already been accepted and the rotation recorded, so an error there aborts nothing -- it propagates out of EndBlock and halts the provider chain, which any bonded validator could trigger by rotating onto a public consumer key. The hook therefore only logs a collision it can no longer prevent, and always returns nil. - drop a duplicate consumer consensus address where the consumer validator set is assembled (CreateConsumerValidators), so a collision that pre-exists in a hand-assembled genesis, or arrives on a chain that never wired the decorator, cannot halt either chain: a set holding two validators at one consensus address panics the provider when it is turned into a CometBFT validator set and halts the consumer when its consensus engine applies the duplicate. Of a colliding pair the entry whose provider consensus address sorts first is kept -- that address is unique per validator, so the rule is a total order that never ties; it is decided from the two addresses alone, so every node keeps the same validator; and it is independent of the power-ranked order the bonded validators arrive in, so a power change cannot silently move the consumer slot from one to the other. Each drop is logged at error level. - consumer keeper.ValidatorIdentifier: identity mapping, since the consumer runs no real staking and no consensus-key rotation. - consumer keeper.ValidatorByConsAddr: answer with a minimal validator echoing the queried consensus address instead of the zero validator -- v0.500.2's rotation-aware HandleValidatorSignature calls GetConsAddr on the result every block, and the zero validator's nil pubkey panics there, consensus-crashing a consumer at its first signed block. Tested: a legitimate rotation migrates the assigned key and evidence attribution to the new provider address; the ante decorator rejects a rotation onto an assigned consumer key, wrapped in authz or nested, and leaves other messages alone; the hook returns nil on a collision it cannot prevent and still migrates the rotating validator's own state; the assembled consumer validator set is duplicate-free under either input order, with the documented tie-break; ValidatorByConsAddr round-trips the queried address without panicking. - scan every non-deleted consumer (not only the active ones) in both the consumer-key collision guard and the rotation migration: a paused consumer keeps its key assignments and can be resumed, so a key taken while it was paused would put two validators at one consensus address on resume, halting the consumer when CometBFT applies the duplicate and the provider when it hashes the set. - migrate every piece of provider state the rotating validator holds under its provider consensus address, not only its key assignments, deciding per consumer by which address the state is read back under (MigrateStateOnConsPubKeyRotation, in the new cons_pubkey_rotation.go). EpochDowntime and WithheldFeeRecords move for every consumer, since DistributeConsumerFees reaches both from the validator's live consensus address. The key assignment, the consumer validator set entry (ConsumerValidators), the queued slashes (PendingDowntimeSlashes) and the acceptance bookkeeping (AcceptedDowntimeWindows, DowntimeWindowFloors) move only for the consumers where the validator has an assigned consumer key, because that is exactly where the address an accusation resolves to changes: the consumer keeps validating under the assigned key, so GetProviderAddrFromConsumerAddr keeps resolving it through the reverse mapping the rotation repoints. Left behind, an accepted window and its floor stop recognising a window re-submitted under the new address, so one offence is accepted -- and slashed -- twice; a queued slash stops being found by HandleChallengeConsumerDowntime, so a false accusation executes with no way to disprove it; a stale valset entry has genuine evidence rejected as naming a validator outside the consumer's set until the next epoch; and an unmoved epoch downtime mark pays the validator for an epoch it had accepted downtime evidence in, with nothing escrowed for a challenge to repay. Where the validator has no assigned key the identity itself changes, and evidence and challenges about the one the consumer already validated under keep resolving to the old address, so its acceptance bookkeeping deliberately stays there -- moving it would produce the same two failures in reverse. Everything the consumer deletion path clears is prefixed by consumer id, so state at either address is still cleaned up. - hand the rotated key to the affected consumers at once instead of at the next epoch boundary (QueueConsPubKeyRotationSnapshots): a validator running its provider key on a consumer changes its consumer signing identity by rotating, and up to BlocksPerEpoch blocks of that gap is up to a full SLA window of misses on every such consumer simultaneously (SignedBlocksWindow 600, MinSignedPerWindow 0.5, so more than 300 misses trips downtime) -- swap the node key at rotation time and it signs with a key the consumer does not know yet, never swap it and every block it produces counts against the old key. The downtime grace period cannot absorb either, being anchored to the consumer's spawn time. Only the consumers whose view changes are snapshotted: where the validator has an assigned consumer key the set the consumer holds does not change, so a snapshot would spend a packet, a valset update id and a relayer round trip delivering the set it already has, and those consumers instead have their valset entry re-keyed in place by the migration above. The packet goes over the client already discovered for the consumer, leaving client discovery to the epoch path, and the step is contained like the rest of the hook: a snapshot that cannot be computed is logged and skipped, a send that fails leaves the packet queued for the next epoch, and nothing propagates out of EndBlock. Also tested: the migration covers every consumer the validator has an assignment on and every reverse mapping naming its old address, including the superseded consumer addresses kept resolvable for pending slashes, while leaving another validator's mappings alone; a rotation preserves the re-submission defence (neither the same window nor one under the pruned acceptance floor is accepted a second time) and keeps the queued slash challengeable, with the escrowed fee share paid out to the rotated validator on a successful challenge; a default-key validator's acceptance bookkeeping stays at its old address and its queued slash is still challengeable there, while its fee-exclusion state follows the rotation; the snapshot goes to the consumer validating the rotated key directly and not to the one holding an assigned key, carrying the rotated key as a full set; and neither an uncomputable snapshot nor an unsendable one makes the hook error. - resolve a rotation when checking that a downtime accusation names a validator in the consumer's set (accusedConsumerValidatorAddr). That set is rebuilt from the bonded validators, so it holds each of them under the provider consensus address it runs now, while an accusation about a window that ended before a rotation names the identity the consumer validated under back then -- and a validator with no assigned consumer key has no reverse mapping to resolve it through, so it resolves to the pre-rotation address, which the set no longer holds. Checked there alone the accusation counts as naming a validator outside the consumer's set and is dropped for good: the provider acks the packet with a failure, and the consumer, which deletes its queued copy when it sends it, never retries. That discarded every accusation not yet accepted when the rotation landed -- the packets in flight, the ones queued on the consumer but not sent, and the window the consumer had not closed yet, whose evidence names the pre-rotation address when it does -- across the whole DowntimeEvidenceMaxAge (72h by default) for which the provider otherwise keeps a window acceptable, for the price of one rotation. So the accused is looked up again under the address x/staking says it holds now, which GetValidatorByConsAddr answers from the old-to-new consensus address mapping a rotation records (liveProviderConsAddr). The second lookup only decides which address the set is searched under, never whether the accused is in it, so a validator the set does not hold is still rejected. What the accusation is then judged against stays keyed by the address it named -- the acceptance floor, the accepted windows and the queued slash -- so a re-submitted window is still recognised and the queued slash still challengeable, while the epoch downtime mark goes to the live address, the only one DistributeConsumerFees reads it back under. And tested: a pre-rotation window's evidence delivered after the rotation is accepted and priced, queued and recorded under the identity the consumer accused, marked for fee exclusion under the rotated address, refused on re-submission, and still falsifiable by a challenge exhibiting the pre-rotation key's sealed signature; an accused that x/staking resolves to a validator the consumer's set does not hold is still rejected, as is one x/staking knows no validator for at all. - name x/slashing the address the punished validator runs now, wherever the equivocation path reaches it (liveConsAddrOf, alongside liveProviderConsAddr). A double-sign committed with a provider consensus key the validator has since rotated away from resolves to the pre-rotation address, there being no key assignment to resolve through, and x/staking still answers for it, so the slash itself lands -- under the live address, which is the one the validator reports. x/slashing does not answer for it: the rotation moved the validator's signing info to the new address and deleted the entry at the old one. So JailUntil failed on the missing entry and failed the whole MsgSubmitConsumerDoubleVoting, after the slash had already moved stake and x/staking had already jailed the validator. Nothing about the evidence changes between submissions, so it failed the same way every time: a rotated validator's equivocation could not be punished at all, and each submitter only lost gas. Only the revert kept that consistent -- had it landed, the validator would have been slashed and jailed but never tombstoned, and IsTombstoned reading the emptied old address would have let every re-submission slash it again. The tombstone check in slashableStake carried the same assumption, so a matured downtime slash queued under a pre-rotation address executed against a tombstoned validator instead of being dropped. Both now read the address x/slashing keys the validator under, derived from the validator x/staking has already returned rather than from a second lookup. Neither widens who can be punished: the resolution runs only once the existing lookup has found the validator, and follows nothing but the rotation x/staking recorded for that same validator, so an address x/staking knows no validator for is rejected exactly as before. Nothing else on this path shares the assumption -- the minimum evidence height is keyed by consumer, not by address, and light-client misbehaviour stays detection-only. Also tested: a double-sign committed with a rotated-away key is slashed, jailed and tombstoned under the address x/slashing holds the validator at, and re-submitting it stays the no-op it has always been rather than a second slash; an accused x/staking knows no validator for is still rejected before x/slashing is reached at all.
giunatale
force-pushed
the
giunatale/feat/consensus-key-rotation
branch
from
July 31, 2026 19:56
231ec34 to
61c2660
Compare
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.
Moves both Go modules to atomone-sdk v0.500.2 and makes VAAS correct
under the consensus-key rotation feature that SDK line ships.
Why
The
replacedirectives pinned a v0.50-era fork while therequirelinesalready declared v0.53-era versions. atomone now ships consensus-key rotation
(
MsgRotateConsPubKey), and VAAS keys per-consumer key-assignment state by theprovider consensus address — a rotation that ignores that state silently breaks
validator identity on every consumer.
What
github.com/cosmos/cosmos-sdk => github.com/atomone-hub/cosmos-sdk v0.500.2in the root andapp/go.mod files.Hooks.AfterConsensusPubKeyUpdate(new interface requirement): onrotation, migrate the validator's key-assignment state — the assigned
consumer key (
ValidatorConsumerPubKey) and every reverse mapping(
ValidatorByConsumerAddr) — from the old provider consensus address to thenew one, for each active consumer. The VSC computation looks assigned keys up
by the validator's current provider address, and cross-chain evidence
attributes through the reverse mapping, so without the migration a rotation
drops the assigned key from the next validator-set update and misattributes
evidence. A validator with no assignment has nothing to migrate; the next
epoch's update carries its rotated key.
validator's consumer key, at transaction admission. The SDK's own
uniqueness check cannot see VAAS assignments and
MsgRotateConsPubKeycarriesno proof of possession of the new key, so without a guard a validator could
rotate onto another validator's consumer key and put two validators at one
consumer consensus address. The guard cannot live in the hook: x/staking
applies a recorded rotation from
ApplyAndReturnValidatorSetUpdates, i.e. inEndBlock, so an error there would halt the chain rather than reject anything.
It is therefore a new ante decorator (
x/vaas/provider/ante, wired in thereference provider app), which also unwraps
authz.MsgExec. The hook itselfnever returns an error; it logs a collision it cannot prevent and migrates.
The collision check is shared with the validator-creation guard, and both now
span every non-deleted consumer — a paused consumer keeps its key assignments
and can be resumed, so a key taken while it was paused would collide on resume.
drops a colliding entry (keeping the lower provider consensus address) and logs
it, so no set with two validators at one consensus address can be stored,
hashed, or sent — including one planted in a hand-assembled genesis or created
on a chain that never wired the decorator.
pending downtime slashes (otherwise a rotation makes a queued accusation
unchallengeable while the slash still executes), accepted downtime windows and
their floors, the epoch-downtime mark, withheld-fee records, and the consumer
validator entry. Fee-path state keyed by the live consensus address migrates
for every consumer; evidence-path state migrates only where an accusation's
resolution actually changes, which is deliberately not the case for a validator
with no assigned key.
validator actually changes (launched consumers where it has no assigned key),
so they learn at once instead of up to a full epoch later — the window in which
the validator would otherwise miss blocks on every such consumer at once.
ValidatorIdentifieron the consumer keeper (new slashinginterface requirement): identity mapping — the consumer runs no real staking
and no rotation.
ValidatorByConsAddrstaking stub to return a minimalvalidator echoing the queried consensus address instead of the zero
validator. v0.500.2's rotation-aware
HandleValidatorSignaturecallsGetConsAddr()on that result every block, and the zero validator's nilpubkey panics there — consensus-crashing a consumer at its first signed
block.
Testing
address; a rotation onto an assigned consumer key is rejected and leaves the
victim's assignment untouched; a legitimate rotation onto a fresh key passes
through the full hook;
ValidatorByConsAddrround-trips the queried addresswithout panicking.
marked under the address the bonded set holds the validator under, not the
rotated-away one the accusation names -- otherwise fee distribution, which
reads the mark back under the live address, would hand a rotated validator a
full epoch share for an epoch it was absent for.
go build(root + app),golangci-lint, and the full unit suite are green.Both Docker e2e suites pass on the stacked client-authentication branch built
on top of this one.