Skip to content

feat: support provider consensus-key rotation on atomone-sdk v0.500.2 - #64

Open
giunatale wants to merge 1 commit into
giunatale/feat/offline-detectionfrom
giunatale/feat/consensus-key-rotation
Open

feat: support provider consensus-key rotation on atomone-sdk v0.500.2#64
giunatale wants to merge 1 commit into
giunatale/feat/offline-detectionfrom
giunatale/feat/consensus-key-rotation

Conversation

@giunatale

@giunatale giunatale commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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 replace directives pinned a v0.50-era fork while the require lines
already declared v0.53-era versions. atomone now ships consensus-key rotation
(MsgRotateConsPubKey), and VAAS keys per-consumer key-assignment state by the
provider consensus address — a rotation that ignores that state silently breaks
validator identity on every consumer.

What

  • Pin github.com/cosmos/cosmos-sdk => github.com/atomone-hub/cosmos-sdk v0.500.2 in the root and app/ go.mod files.
  • Implement Hooks.AfterConsensusPubKeyUpdate (new interface requirement): on
    rotation, migrate the validator's key-assignment state — the assigned
    consumer key (ValidatorConsumerPubKey) and every reverse mapping
    (ValidatorByConsumerAddr) — from the old provider consensus address to the
    new 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.
  • Reject a rotation onto a consensus key that is already assigned as some
    validator's consumer key, at transaction admission. The SDK's own
    uniqueness check cannot see VAAS assignments and MsgRotateConsPubKey carries
    no 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. in
    EndBlock, 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 the
    reference provider app), which also unwraps authz.MsgExec. The hook itself
    never 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.
  • Defence in depth: the consumer validator-set construction deterministically
    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.
  • Migrate the downtime and fee bookkeeping a rotation would otherwise strand:
    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.
  • Queue an immediate validator-set snapshot for the consumers whose view of the
    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.
  • Implement ValidatorIdentifier on the consumer keeper (new slashing
    interface requirement): identity mapping — the consumer runs no real staking
    and no rotation.
  • Fix the consumer's ValidatorByConsAddr staking stub to return a minimal
    validator echoing the queried consensus address instead of the zero
    validator. v0.500.2's rotation-aware HandleValidatorSignature calls
    GetConsAddr() on that result every block, and the zero validator's nil
    pubkey panics there — consensus-crashing a consumer at its first signed
    block.

Testing

  • Unit: rotation migrates the assigned key and evidence attribution to the new
    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; ValidatorByConsAddr round-trips the queried address
    without panicking.
  • Unit: the epoch fee exclusion an accepted downtime accusation carries is
    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.

@giunatale

Copy link
Copy Markdown
Contributor Author

Branched from giunatale/feat/offline-detection (#63).
Opened against that branch so only this PR's commit shows; will retarget to main after #63 lands and this rebases.

@giunatale giunatale changed the title feat: support provider consensus-key rotation on cosmos-sdk v0.500.2 feat: support provider consensus-key rotation on atomone-sdk v0.500.2 Jul 30, 2026
@giunatale
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from 68f0322 to 231ec34 Compare July 31, 2026 18:40
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
giunatale force-pushed the giunatale/feat/consensus-key-rotation branch from 231ec34 to 61c2660 Compare July 31, 2026 19:56
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.

1 participant