Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9a48941
add protos & generate
mattac21 May 20, 2026
cb87501
implement x/staking msg server handler for MsgRotateConsPubKey
mattac21 May 20, 2026
4908310
add comments to store keys
mattac21 May 21, 2026
a4e1182
implement staking endblocker to perform key rotations
mattac21 May 21, 2026
90a2a03
prune key rotations from the store that have fallen out of their unbo…
mattac21 May 21, 2026
6148927
burn key rotation fee instad of going to community pool
mattac21 May 21, 2026
44361e1
add consensus key rotation integration tests
mattac21 May 21, 2026
af08ef6
changelog
mattac21 May 21, 2026
733e182
RotateConsPubKey godoc
mattac21 May 21, 2026
99d977b
allow any bond status to rotate cons keys, dont allow jailed to rotat…
mattac21 May 21, 2026
2d6870f
change to function name HasPendingConsKeyRotation to HasConsKeyRotati…
mattac21 May 21, 2026
cb7697a
dont discard GetValidator errors when checking if new consensus key i…
mattac21 May 21, 2026
5681d5c
send key rotation fee to new staking module account key_rotation_fee_…
mattac21 May 21, 2026
c12ff76
update validator not bonded test to validator not jailed
mattac21 May 21, 2026
60e55ee
fetch the key rotation fee pool module account in staking genesis to …
mattac21 May 21, 2026
c3a5171
add todo to update slashing
mattac21 May 21, 2026
b0137af
block rotations to addresses that are already have a pending rotation
mattac21 May 22, 2026
d731307
dont delete during iteration
mattac21 May 22, 2026
738906e
remove unused
mattac21 May 22, 2026
bd91ed0
Merge branch 'main' into ma/key-rotation
mattac21 May 26, 2026
de8f4c6
Merge branch 'main' into ma/key-rotation
mattac21 Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
* (abci) [#25620](https://github.com/cosmos/cosmos-sdk/pull/25620) Add support for new application side mempool ABCI methods.
* (abci) [#25969](https://github.com/cosmos/cosmos-sdk/pull/25969) Add support for new ABCI methods, `InsertTx` and `ReapTxs`.
* (deps) [#26388](https://github.com/cosmos/cosmos-sdk/pull/26388) Bump CometBFT version to v0.39.3.
* (staking) [#26440](https://github.com/cosmos/cosmos-sdk/pull/26440) Add basic key rotation for validator consensus keys.

### Improvements

Expand Down
22 changes: 21 additions & 1 deletion proto/cosmos/staking/v1beta1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ service Msg {
rpc UpdateParams(MsgUpdateParams) returns (MsgUpdateParamsResponse) {
option (cosmos_proto.method_added_in) = "cosmos-sdk 0.47";
};

// RotateConsPubKey defines an operation for rotating the consensus keys
// of a validator.
rpc RotateConsPubKey(MsgRotateConsPubKey) returns (MsgRotateConsPubKeyResponse);
}

// MsgCreateValidator defines a SDK message for creating a new validator.
Expand Down Expand Up @@ -201,4 +205,20 @@ message MsgUpdateParams {
// MsgUpdateParams message.
message MsgUpdateParamsResponse {
option (cosmos_proto.message_added_in) = "cosmos-sdk 0.47";
};
}

// MsgRotateConsPubKey is the Msg/RotateConsPubKey request type.
message MsgRotateConsPubKey {
option (cosmos.msg.v1.signer) = "validator_address";
option (amino.name) = "cosmos-sdk/MsgRotateConsPubKey";

option (gogoproto.goproto_getters) = false;
option (gogoproto.equal) = false;

string validator_address = 1 [(cosmos_proto.scalar) = "cosmos.ValidatorAddressString"];
google.protobuf.Any new_pubkey = 2 [(cosmos_proto.accepts_interface) = "cosmos.crypto.PubKey"];
}

// MsgRotateConsPubKeyResponse defines the response structure for executing a
// MsgRotateConsPubKey message.
message MsgRotateConsPubKeyResponse {};
250 changes: 250 additions & 0 deletions tests/integration/staking/keeper/cons_key_rotation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package keeper_test

import (
"testing"
"time"

cmtabcitypes "github.com/cometbft/cometbft/abci/types"
"gotest.tools/v3/assert"

codectypes "github.com/cosmos/cosmos-sdk/codec/types"
"github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"
cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types"
simtestutil "github.com/cosmos/cosmos-sdk/testutil/sims"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/x/staking/keeper"
"github.com/cosmos/cosmos-sdk/x/staking/types"
)

// Covers msg-server queuing plus end-blocker application: the fee transfer,
// all four store indexes, the deferred swap of the validators stored
// ConsensusPubkey, and the two ABCI updates CometBFT needs to retire the old
// key at zero power and instate the new key at the current power.
func TestRotateConsPubKey_MsgServerQueuesAndEndBlockerApplies(t *testing.T) {
t.Parallel()
f := initFixture(t)
msgServer := keeper.NewMsgServerImpl(f.stakingKeeper)
bondDenom, err := f.stakingKeeper.BondDenom(f.sdkCtx)
assert.NilError(t, err)

oldPk := ed25519.GenPrivKey().PubKey()
newPk := ed25519.GenPrivKey().PubKey()
valAddr, accAddr := bondConsKeyRotationValidator(t, f, oldPk)
oldConsAddr := sdk.ConsAddress(oldPk.Address())
newConsAddr := sdk.ConsAddress(newPk.Address())

accBalBefore := f.bankKeeper.GetBalance(f.sdkCtx, accAddr, bondDenom)
supplyBefore := f.bankKeeper.GetSupply(f.sdkCtx, bondDenom)

valBefore, err := f.stakingKeeper.GetValidator(f.sdkCtx, valAddr)
assert.NilError(t, err)
powerReduction := f.stakingKeeper.PowerReduction(f.sdkCtx)
powerBefore := valBefore.ConsensusPower(powerReduction)
assert.Assert(t, powerBefore > 0)

_, err = msgServer.RotateConsPubKey(f.sdkCtx, &types.MsgRotateConsPubKey{
ValidatorAddress: valAddr.String(),
NewPubkey: newPubKeyAny(t, newPk),
})
assert.NilError(t, err)

// fee debited from the operator account and burned (total supply
// decreases by exactly the fee)
fee := types.DefaultKeyRotationFee
assert.DeepEqual(t, accBalBefore.Sub(fee), f.bankKeeper.GetBalance(f.sdkCtx, accAddr, bondDenom))
assert.DeepEqual(t, supplyBefore.Sub(fee), f.bankKeeper.GetSupply(f.sdkCtx, bondDenom))

// per-validator pending index recorded (gates further rotations inside the
// unbonding window)
hasPending, err := f.stakingKeeper.HasPendingConsKeyRotation(f.sdkCtx, valAddr)
assert.NilError(t, err)
assert.Assert(t, hasPending)

// maturity queue entry recorded at BlockTime + UnbondingTime
unbondingTime, err := f.stakingKeeper.UnbondingTime(f.sdkCtx)
assert.NilError(t, err)
maturity := f.sdkCtx.BlockHeader().Time.Add(unbondingTime)
hasQueue, err := f.stakingKeeper.HasConsKeyRotationQueueEntry(f.sdkCtx, maturity, valAddr)
assert.NilError(t, err)
assert.Assert(t, hasQueue)

// rotated cons addr index recorded so the old key still resolves to this
// validator for slashing/evidence routing
hasRotated, err := f.stakingKeeper.HasRotatedConsAddr(f.sdkCtx, oldConsAddr)
assert.NilError(t, err)
assert.Assert(t, hasRotated)

// validators stored ConsensusPubkey is unchanged until the end blocker runs
preEndBlocker, err := f.stakingKeeper.GetValidator(f.sdkCtx, valAddr)
assert.NilError(t, err)
preConsAddr, err := preEndBlocker.GetConsAddr()
assert.NilError(t, err)
assert.DeepEqual(t, oldConsAddr.Bytes(), preConsAddr)

// advance one block at the current block time so the end blocker applies
// the rotation but does not yet prune (maturity is in the future)
advanceBlock(t, f, f.sdkCtx.BlockHeader().Time)

// old by-cons-addr index is gone
_, err = f.stakingKeeper.GetValidatorByConsAddr(f.sdkCtx, oldConsAddr)
assert.ErrorContains(t, err, types.ErrNoValidatorFound.Error())

// new by-cons-addr index resolves to this validator
byNew, err := f.stakingKeeper.GetValidatorByConsAddr(f.sdkCtx, newConsAddr)
assert.NilError(t, err)
assert.Equal(t, valAddr.String(), byNew.OperatorAddress)

// validators stored ConsensusPubkey now reflects newPk and power is
// unchanged
stored, err := f.stakingKeeper.GetValidator(f.sdkCtx, valAddr)
assert.NilError(t, err)
storedConsAddr, err := stored.GetConsAddr()
assert.NilError(t, err)
assert.DeepEqual(t, newConsAddr.Bytes(), storedConsAddr)
assert.Equal(t, powerBefore, stored.ConsensusPower(powerReduction))

// the per-validator pending index intentionally persists past the end
// blocker so that further rotations are gated until the end blocker
// prunes it after maturity
hasPendingAfter, err := f.stakingKeeper.HasPendingConsKeyRotation(f.sdkCtx, valAddr)
assert.NilError(t, err)
assert.Assert(t, hasPendingAfter)
}

// Covers PruneMaturedConsKeyRotations (called from the end blocker) clearing
// the maturity queue, the per-validator pending index, and the rotated cons
// addr index once the unbonding window has elapsed.
func TestRotateConsPubKey_PruneClearsRotationStateAfterUnbonding(t *testing.T) {
t.Parallel()
f := initFixture(t)
msgServer := keeper.NewMsgServerImpl(f.stakingKeeper)

oldPk := ed25519.GenPrivKey().PubKey()
newPk := ed25519.GenPrivKey().PubKey()
valAddr, _ := bondConsKeyRotationValidator(t, f, oldPk)
oldConsAddr := sdk.ConsAddress(oldPk.Address())

_, err := msgServer.RotateConsPubKey(f.sdkCtx, &types.MsgRotateConsPubKey{
ValidatorAddress: valAddr.String(),
NewPubkey: newPubKeyAny(t, newPk),
})
assert.NilError(t, err)

unbondingTime, err := f.stakingKeeper.UnbondingTime(f.sdkCtx)
assert.NilError(t, err)
maturity := f.sdkCtx.BlockHeader().Time.Add(unbondingTime)

// first block at current time: applies the rotation, maturity is in
// the future so no pruning happens
advanceBlock(t, f, f.sdkCtx.BlockHeader().Time)

has, err := f.stakingKeeper.HasConsKeyRotationQueueEntry(f.sdkCtx, maturity, valAddr)
assert.NilError(t, err)
assert.Assert(t, has)

// second block past maturity: the end blocker prunes
advanceBlock(t, f, maturity.Add(time.Second))

has, err = f.stakingKeeper.HasConsKeyRotationQueueEntry(f.sdkCtx, maturity, valAddr)
assert.NilError(t, err)
assert.Assert(t, !has, "maturity queue entry should be pruned")

hasPending, err := f.stakingKeeper.HasPendingConsKeyRotation(f.sdkCtx, valAddr)
assert.NilError(t, err)
assert.Assert(t, !hasPending, "per-validator pending index should be pruned")

hasRotated, err := f.stakingKeeper.HasRotatedConsAddr(f.sdkCtx, oldConsAddr)
assert.NilError(t, err)
assert.Assert(t, !hasRotated, "rotated cons addr index should be pruned")
}

// Covers the per-window rotation cap lifting after pruning, and that the
// original consensus pubkey can be reused once it leaves the rotation history.
func TestRotateConsPubKey_SecondRotationAfterPruningSucceeds(t *testing.T) {
t.Parallel()
f := initFixture(t)
msgServer := keeper.NewMsgServerImpl(f.stakingKeeper)

pkA := ed25519.GenPrivKey().PubKey()
pkB := ed25519.GenPrivKey().PubKey()
pkC := ed25519.GenPrivKey().PubKey()
valAddr, _ := bondConsKeyRotationValidator(t, f, pkA)

// first rotation A -> B
_, err := msgServer.RotateConsPubKey(f.sdkCtx, &types.MsgRotateConsPubKey{
ValidatorAddress: valAddr.String(),
NewPubkey: newPubKeyAny(t, pkB),
})
assert.NilError(t, err)
advanceBlock(t, f, f.sdkCtx.BlockHeader().Time)

// a second rotation inside the unbonding window is rejected
_, err = msgServer.RotateConsPubKey(f.sdkCtx, &types.MsgRotateConsPubKey{
ValidatorAddress: valAddr.String(),
NewPubkey: newPubKeyAny(t, pkC),
})
assert.ErrorContains(t, err, types.ErrExceedingMaxConsPubKeyRotations.Error())

// advance past maturity and let the end blocker prune
unbondingTime, err := f.stakingKeeper.UnbondingTime(f.sdkCtx)
assert.NilError(t, err)
advanceBlock(t, f, f.sdkCtx.BlockHeader().Time.Add(unbondingTime).Add(time.Second))

// second rotation back to pkA (the original key) succeeds: the rotation
// history was cleared by pruning
_, err = msgServer.RotateConsPubKey(f.sdkCtx, &types.MsgRotateConsPubKey{
ValidatorAddress: valAddr.String(),
NewPubkey: newPubKeyAny(t, pkA),
})
assert.NilError(t, err)
advanceBlock(t, f, f.sdkCtx.BlockHeader().Time)

stored, err := f.stakingKeeper.GetValidator(f.sdkCtx, valAddr)
assert.NilError(t, err)
storedConsAddr, err := stored.GetConsAddr()
assert.NilError(t, err)
assert.DeepEqual(t, sdk.ConsAddress(pkA.Address()).Bytes(), storedConsAddr)
}

// bondConsKeyRotationValidator creates and bonds a single validator under
// consPk, funding the operator account with enough tokens to cover several
// rotation fees plus the self delegation.
func bondConsKeyRotationValidator(t *testing.T, f *fixture, consPk cryptotypes.PubKey) (sdk.ValAddress, sdk.AccAddress) {
t.Helper()
addrs := simtestutil.AddTestAddrsIncremental(f.bankKeeper, f.stakingKeeper, f.sdkCtx, 1, f.stakingKeeper.TokensFromConsensusPower(f.sdkCtx, 300))
valAddr := sdk.ValAddress(addrs[0])

v, err := types.NewValidator(valAddr.String(), consPk, types.NewDescription("v", "", "", "", ""))
assert.NilError(t, err)
assert.NilError(t, f.stakingKeeper.SetValidator(f.sdkCtx, v))
assert.NilError(t, f.stakingKeeper.SetValidatorByConsAddr(f.sdkCtx, v))
assert.NilError(t, f.stakingKeeper.SetNewValidatorByPowerIndex(f.sdkCtx, v))

_, err = f.stakingKeeper.Delegate(f.sdkCtx, addrs[0], f.stakingKeeper.TokensFromConsensusPower(f.sdkCtx, 100), types.Unbonded, v, true)
assert.NilError(t, err)

applyValidatorSetUpdates(t, f.sdkCtx, f.stakingKeeper, 1)

return valAddr, addrs[0]
}

func newPubKeyAny(t *testing.T, pk cryptotypes.PubKey) *codectypes.Any {
t.Helper()
a, err := codectypes.NewAnyWithValue(pk)
assert.NilError(t, err)
return a
}

// advanceBlock advances the chain by one block at blockTime, driving the
// staking end blocker through the real ABCI flow so that pending consensus
// key rotations are applied and any matured rotation entries are pruned.
func advanceBlock(t *testing.T, f *fixture, blockTime time.Time) {
t.Helper()
_, err := f.app.FinalizeBlock(&cmtabcitypes.RequestFinalizeBlock{
Height: f.app.LastBlockHeight() + 1,
Time: blockTime,
})
assert.NilError(t, err)
_, err = f.app.Commit()
assert.NilError(t, err)
}
13 changes: 9 additions & 4 deletions testutil/integration/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,16 @@ func NewIntegrationApp(
return &cmtabcitypes.ResponseInitChain{}, nil
})

bApp.SetBeginBlocker(func(_ sdk.Context) (sdk.BeginBlock, error) {
return moduleManager.BeginBlock(sdkCtx)
// the integration helper keeps module state on the externally provided
// sdkCtx (cms), but FinalizeBlock passes a ctx whose header reflects the
// current block. forward only the block time so begin and end blockers
// can act on time-dependent state (e.g. matured queues) while still
// operating on the shared store and the captured initial block height.
bApp.SetBeginBlocker(func(ctx sdk.Context) (sdk.BeginBlock, error) {
return moduleManager.BeginBlock(sdkCtx.WithBlockTime(ctx.BlockTime()))
})
bApp.SetEndBlocker(func(_ sdk.Context) (sdk.EndBlock, error) {
return moduleManager.EndBlock(sdkCtx)
bApp.SetEndBlocker(func(ctx sdk.Context) (sdk.EndBlock, error) {
return moduleManager.EndBlock(sdkCtx.WithBlockTime(ctx.BlockTime()))
})

router := baseapp.NewMsgServiceRouter()
Expand Down
76 changes: 76 additions & 0 deletions x/staking/keeper/msg_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,79 @@ func (k msgServer) UpdateParams(ctx context.Context, msg *types.MsgUpdateParams)

return &types.MsgUpdateParamsResponse{}, nil
}

// RotateConsPubKey defines a method for changing a validators consensus key to
// a new key.
func (k msgServer) RotateConsPubKey(ctx context.Context, msg *types.MsgRotateConsPubKey) (*types.MsgRotateConsPubKeyResponse, error) {
newPk, ok := msg.NewPubkey.GetCachedValue().(cryptotypes.PubKey)
if !ok {
return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidType, "expecting cryptotypes.PubKey, got %T", msg.NewPubkey.GetCachedValue())
}
newConsAddr := sdk.ConsAddress(newPk.Address())

// reject reuse of a key that some validator rotated away from inside the
// unbonding window
rotated, err := k.HasRotatedConsAddr(ctx, newConsAddr)
if err != nil {
return nil, err
}
if rotated {
return nil, types.ErrConsensusPubKeyInRotationHistory
}

// reject a key currently in use by some validator
if existing, err := k.GetValidatorByConsAddr(ctx, newConsAddr); err == nil && existing.OperatorAddress != "" {
Comment thread
mattac21 marked this conversation as resolved.
Outdated
return nil, types.ErrConsensusPubKeyAlreadyUsedForValidator
}

valAddr, err := k.validatorAddressCodec.StringToBytes(msg.ValidatorAddress)
if err != nil {
return nil, sdkerrors.ErrInvalidAddress.Wrapf("invalid validator address: %s", err)
}
validator, err := k.GetValidator(ctx, valAddr)
if err != nil {
return nil, types.ErrNoValidatorFound
}

// TODO: this is likely too strict, we probably only need to restrict to
// not allowing tombstoned validators to rotate
if status := validator.GetStatus(); status != types.Bonded {
Comment thread
mattac21 marked this conversation as resolved.
Outdated
return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, "validator status is not bonded, got %s", status)
}
Comment thread
mattac21 marked this conversation as resolved.

// shouldnt ever happen
oldPk, ok := validator.ConsensusPubkey.GetCachedValue().(cryptotypes.PubKey)
if !ok {
return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidType, "expecting cryptotypes.PubKey for validator's current key, got %T", validator.ConsensusPubkey.GetCachedValue())
}

// enforce the per validator rotation limit inside the unbonding window
pending, err := k.HasPendingConsKeyRotation(ctx, valAddr)
if err != nil {
return nil, err
}
if pending {
return nil, types.ErrExceedingMaxConsPubKeyRotations
}

// burn the rotation fee. NotBondedPool is used as the transit account
// because BurnCoins requires a module account with Burner permission.

// TODO: is there an easier way to burn without having to go to the not
// bonded pool/module account first? seems like no
fee := types.DefaultKeyRotationFee
feeCoins := sdk.NewCoins(fee)
if err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sdk.AccAddress(valAddr), types.NotBondedPoolName, feeCoins); err != nil {
return nil, err
}
if err := k.bankKeeper.BurnCoins(ctx, types.NotBondedPoolName, feeCoins); err != nil {
Comment thread
mattac21 marked this conversation as resolved.
Outdated
return nil, err
}

// record the key rotation in the store
if err := k.SetConsKeyRotation(ctx, valAddr, oldPk, newPk, fee); err != nil {
return nil, err
}

Comment thread
mattac21 marked this conversation as resolved.
return &types.MsgRotateConsPubKeyResponse{}, nil
}
Loading
Loading