From f2ea48f3465654b6748e64508c36326c58fd2fdf Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 2 Jul 2026 13:04:12 -0400 Subject: [PATCH 01/28] scripts: add chain-helper network defaults --- scripts/chain-helper.sh | 86 ++++++++++++++++++++++++++++--- tests/scripts/chain-helper.bats | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/scripts/chain-helper.sh b/scripts/chain-helper.sh index da91b02e..dcf37eaf 100755 --- a/scripts/chain-helper.sh +++ b/scripts/chain-helper.sh @@ -2,10 +2,23 @@ set -euo pipefail IFS=$'\n\t' -DEFAULT_CHAIN_ID="lumera-mainnet-1" -DEFAULT_RPC="https://rpc.lumera.io:443" -DEFAULT_GRPC="grpc.lumera.io:443" -DEFAULT_CURRENT_CAP="2000" +DEVNET_CHAIN_ID="lumera-devnet-1" +DEVNET_RPC="https://rpc.pastel.network" +DEVNET_GRPC="grpc.pastel.network" + +TESTNET_CHAIN_ID="lumera-testnet-2" +#TESTNET_RPC="https://lumera-testnet-rpc.polkachu.com:443" +TESTNET_RPC="https://rpc.testnet.lumera.io:443" +TESTNET_GRPC="grpc.testnet.lumera.io:443" + +MAINNET_CHAIN_ID="lumera-mainnet-1" +MAINNET_RPC="https://rpc.lumera.io:443" +MAINNET_GRPC="grpc.lumera.io:443" + +DEFAULT_CHAIN_ID="$TESTNET_CHAIN_ID" +DEFAULT_RPC="$TESTNET_RPC" +DEFAULT_GRPC="$TESTNET_GRPC" +DEFAULT_CURRENT_CAP="2500" CHAIN_ID="${LUMERA_CHAIN_ID:-$DEFAULT_CHAIN_ID}" NODE="${LUMERA_NODE:-${LUMERA_RPC:-$DEFAULT_RPC}}" @@ -18,6 +31,9 @@ JSON_OUTPUT=0 ALLOW_PARTIAL=0 GRPC_INSECURE="${LUMERA_GRPC_INSECURE:-0}" GRPC_MAX_TIME="${LUMERA_GRPC_MAX_TIME:-30}" +CHAIN_ID_FLAG_SET=0 +NODE_FLAG_SET=0 +GRPC_FLAG_SET=0 usage() { cat <<'USAGE' @@ -33,11 +49,12 @@ Commands: total supernodes grouped by their current state. Common flags: + --network Use default endpoints for devnet, testnet, or mainnet --node, --rpc Tendermint RPC endpoint - default: https://rpc.lumera.io:443 + default: selected network RPC --grpc gRPC endpoint for commands that need it - default: grpc.lumera.io:443 - --chain-id Chain ID; default: lumera-mainnet-1 + default: selected network gRPC + --chain-id Chain ID; default: selected network chain ID --binary lumerad binary; default: lumerad --grpcurl grpcurl binary; default: grpcurl --grpc-insecure use plaintext gRPC for grpcurl queries @@ -101,6 +118,40 @@ require_grpcurl() { command -v "$GRPCURL" >/dev/null 2>&1 || return 1 } +apply_network_defaults() { + local network="$1" chain_id rpc grpc + case "$network" in + devnet) + chain_id="$DEVNET_CHAIN_ID" + rpc="$DEVNET_RPC" + grpc="$DEVNET_GRPC" + ;; + testnet) + chain_id="$TESTNET_CHAIN_ID" + rpc="$TESTNET_RPC" + grpc="$TESTNET_GRPC" + ;; + mainnet) + chain_id="$MAINNET_CHAIN_ID" + rpc="$MAINNET_RPC" + grpc="$MAINNET_GRPC" + ;; + *) + die 1 "unknown network '$network'; expected devnet, testnet, or mainnet" + ;; + esac + + if [[ "$CHAIN_ID_FLAG_SET" -eq 0 ]]; then + CHAIN_ID="$chain_id" + fi + if [[ "$NODE_FLAG_SET" -eq 0 ]]; then + NODE="$rpc" + fi + if [[ "$GRPC_FLAG_SET" -eq 0 ]]; then + GRPC="$grpc" + fi +} + lumerad_query() { "$BIN" query "$@" \ --node "$NODE" \ @@ -315,19 +366,27 @@ emit_result_human() { parse_max_validator_flags() { while (($#)); do case "$1" in + --network) + require_value "$1" "${2:-}" + apply_network_defaults "$2" + shift 2 + ;; --node|--rpc) require_value "$1" "${2:-}" NODE="$2" + NODE_FLAG_SET=1 shift 2 ;; --grpc) require_value "$1" "${2:-}" GRPC="$2" + GRPC_FLAG_SET=1 shift 2 ;; --chain-id) require_value "$1" "${2:-}" CHAIN_ID="$2" + CHAIN_ID_FLAG_SET=1 shift 2 ;; --binary) @@ -587,14 +646,27 @@ fetch_all() { parse_stats_flags() { while (($#)); do case "$1" in + --network) + require_value "$1" "${2:-}" + apply_network_defaults "$2" + shift 2 + ;; --node|--rpc) require_value "$1" "${2:-}" NODE="$2" + NODE_FLAG_SET=1 + shift 2 + ;; + --grpc) + require_value "$1" "${2:-}" + GRPC="$2" + GRPC_FLAG_SET=1 shift 2 ;; --chain-id) require_value "$1" "${2:-}" CHAIN_ID="$2" + CHAIN_ID_FLAG_SET=1 shift 2 ;; --binary) diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index 262bc83b..a9163c0e 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -262,6 +262,83 @@ teardown() { ' } +@test "max-validator-delegations applies network defaults" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network mainnet \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "lumera-mainnet-1" + and .rpc == "https://rpc.lumera.io:443" + and .grpc == "grpc.lumera.io:443" + ' +} + +@test "max-validator-delegations points devnet network at configured devnet" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network devnet \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "lumera-devnet-1" + and .rpc == "https://rpc.pastel.network" + and .grpc == "grpc.pastel.network" + ' +} + +@test "max-validator-delegations lets explicit endpoint flags override network defaults" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network mainnet \ + --chain-id custom-chain \ + --node tcp://custom-rpc:26657 \ + --grpc custom-grpc:9090 \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "custom-chain" + and .rpc == "tcp://custom-rpc:26657" + and .grpc == "custom-grpc:9090" + ' +} + +@test "network overrides work even when endpoint flags appear before network" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --chain-id custom-chain \ + --node tcp://custom-rpc:26657 \ + --grpc custom-grpc:9090 \ + --network testnet \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "custom-chain" + and .rpc == "tcp://custom-rpc:26657" + and .grpc == "custom-grpc:9090" + ' +} + +@test "max-validator-delegations rejects unknown network" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network staging \ + --json + + [ "$status" -eq 1 ] + [[ "$output" == *"unknown network"* ]] +} + @test "max-validator-delegations uses staking and grpcurl on pre-evm chain" { run env FAKE_EXACT_UNAVAILABLE=1 \ "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ @@ -373,6 +450,19 @@ teardown() { ' } +@test "stats applies testnet network defaults" { + run "$SCRIPTS_DIR/chain-helper.sh" stats \ + --binary "$FAKE_LUMERAD" \ + --network testnet \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "lumera-testnet-2" + and .rpc == "https://rpc.testnet.lumera.io:443" + ' +} + @test "stats human output groups supernodes by current (highest-height) state" { run "$SCRIPTS_DIR/chain-helper.sh" stats \ --binary "$FAKE_LUMERAD" \ From a3a4b75c2a260ff9e3cb43c7863b062c5e575629 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 2 Jul 2026 23:11:52 -0400 Subject: [PATCH 02/28] fix evmigration validator scoped iteration --- app/app.go | 4 + x/evmigration/keeper/keeper.go | 23 +- x/evmigration/keeper/migrate_test.go | 256 +++++++++++++++++- x/evmigration/keeper/migrate_validator.go | 35 +-- .../keeper/migrate_validator_scoped.go | 141 ++++++++++ .../keeper/msg_server_claim_legacy_test.go | 24 +- .../keeper/msg_server_migrate_validator.go | 11 +- .../msg_server_migrate_validator_test.go | 63 ++++- x/evmigration/keeper/query.go | 11 +- x/evmigration/keeper/query_test.go | 81 ++++++ 10 files changed, 589 insertions(+), 60 deletions(-) create mode 100644 x/evmigration/keeper/migrate_validator_scoped.go diff --git a/app/app.go b/app/app.go index c7938745..f697a71b 100644 --- a/app/app.go +++ b/app/app.go @@ -54,6 +54,7 @@ import ( consensuskeeper "github.com/cosmos/cosmos-sdk/x/consensus/keeper" _ "github.com/cosmos/cosmos-sdk/x/distribution" // import for side-effects distrkeeper "github.com/cosmos/cosmos-sdk/x/distribution/keeper" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" "github.com/cosmos/cosmos-sdk/x/genutil" genutiltypes "github.com/cosmos/cosmos-sdk/x/genutil/types" "github.com/cosmos/cosmos-sdk/x/gov" @@ -366,6 +367,9 @@ func New( app.EvmigrationKeeper.SetStakingStoreService( runtime.NewKVStoreService(app.GetKey(stakingtypes.StoreKey)), ) + app.EvmigrationKeeper.SetDistributionStoreService( + runtime.NewKVStoreService(app.GetKey(distrtypes.StoreKey)), + ) // configure EVM coin info (must happen before EVM module keepers are created) if err := appevm.Configure(); err != nil { diff --git a/x/evmigration/keeper/keeper.go b/x/evmigration/keeper/keeper.go index c57b58f3..3be79061 100644 --- a/x/evmigration/keeper/keeper.go +++ b/x/evmigration/keeper/keeper.go @@ -20,6 +20,13 @@ type stakingStoreHandle struct { svc corestore.KVStoreService } +// distributionStoreHandle holds a mutable reference to distribution's +// KVStoreService. It is wired post-depinject via SetDistributionStoreService and +// used only by migration code that must range over validator-scoped prefixes. +type distributionStoreHandle struct { + svc corestore.KVStoreService +} + type Keeper struct { storeService corestore.KVStoreService cdc codec.Codec @@ -33,6 +40,9 @@ type Keeper struct { // x/staking's KV namespace. Wired post-build in app.go via // SetStakingStoreService; used exclusively by DeleteValidatorRecordNoHooks. stakingStoreHandle *stakingStoreHandle + // distributionStoreHandle grants this keeper migration-only raw read access + // to x/distribution's KV namespace for validator-scoped iteration. + distributionStoreHandle *distributionStoreHandle // MigrationRecords stores completed migration records keyed by legacy address. MigrationRecords collections.Map[string, types.MigrationRecord] @@ -107,7 +117,8 @@ func NewKeeper( // Allocate once so value-copies of Keeper (e.g. app.EvmigrationKeeper // and AppModule.keeper) share the same mutable handle. app.go writes // the staking store service into it post-build. - stakingStoreHandle: &stakingStoreHandle{}, + stakingStoreHandle: &stakingStoreHandle{}, + distributionStoreHandle: &distributionStoreHandle{}, } schema, err := sb.Build() @@ -135,3 +146,13 @@ func (k Keeper) GetAuthority() []byte { func (k *Keeper) SetStakingStoreService(svc corestore.KVStoreService) { k.stakingStoreHandle.svc = svc } + +// SetDistributionStoreService wires the distribution module's KVStoreService +// into this keeper. Required for production validator migration to iterate only +// validator-scoped distribution prefixes instead of chain-wide stores. +// +// UNSAFE / MIGRATION-ONLY: this grants raw read access to x/distribution's KV +// namespace. Do NOT use for unrelated keeper logic. +func (k *Keeper) SetDistributionStoreService(svc corestore.KVStoreService) { + k.distributionStoreHandle.svc = svc +} diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index b0adff28..5fc82d87 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -1,12 +1,15 @@ package keeper_test import ( + "errors" "fmt" "testing" + corestore "cosmossdk.io/core/store" "cosmossdk.io/math" storetypes "cosmossdk.io/store/types" "cosmossdk.io/x/feegrant" + "github.com/cosmos/cosmos-sdk/codec" addresscodec "github.com/cosmos/cosmos-sdk/codec/address" kmultisig "github.com/cosmos/cosmos-sdk/crypto/keys/multisig" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" @@ -37,6 +40,9 @@ import ( type mockFixture struct { ctx sdk.Context keeper keeper.Keeper + cdc codec.Codec + stakingStore corestore.KVStoreService + distributionStore corestore.KVStoreService accountKeeper *evmigrationmocks.MockAccountKeeper bankKeeper *evmigrationmocks.MockBankKeeper stakingKeeper *evmigrationmocks.MockStakingKeeper @@ -66,8 +72,20 @@ func initMockFixture(t *testing.T) *mockFixture { encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModule{}) addrCodec := addresscodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) storeKey := storetypes.NewKVStoreKey(types.StoreKey) + stakingStoreKey := storetypes.NewKVStoreKey(stakingtypes.StoreKey) + distributionStoreKey := storetypes.NewKVStoreKey(distrtypes.StoreKey) storeService := runtime.NewKVStoreService(storeKey) - ctx := testutil.DefaultContextWithDB(t, storeKey, storetypes.NewTransientStoreKey("transient_test")).Ctx + stakingStoreService := runtime.NewKVStoreService(stakingStoreKey) + distributionStoreService := runtime.NewKVStoreService(distributionStoreKey) + ctx := testutil.DefaultContextWithKeys( + map[string]*storetypes.KVStoreKey{ + types.StoreKey: storeKey, + stakingtypes.StoreKey: stakingStoreKey, + distrtypes.StoreKey: distributionStoreKey, + }, + map[string]*storetypes.TransientStoreKey{"transient_test": storetypes.NewTransientStoreKey("transient_test")}, + nil, + ) authority := authtypes.NewModuleAddress(types.GovModuleName) @@ -96,6 +114,9 @@ func initMockFixture(t *testing.T) *mockFixture { return &mockFixture{ ctx: ctx, keeper: k, + cdc: encCfg.Codec, + stakingStore: stakingStoreService, + distributionStore: distributionStoreService, accountKeeper: accountKeeper, bankKeeper: bankKeeper, stakingKeeper: stakingKeeper, @@ -108,6 +129,70 @@ func initMockFixture(t *testing.T) *mockFixture { } } +func (f *mockFixture) wireScopedMigrationStores() { + f.keeper.SetStakingStoreService(f.stakingStore) + f.keeper.SetDistributionStoreService(f.distributionStore) +} + +func (f *mockFixture) writeRedelegation(red stakingtypes.Redelegation) { + delegator, err := sdk.AccAddressFromBech32(red.DelegatorAddress) + if err != nil { + panic(err) + } + src, err := sdk.ValAddressFromBech32(red.ValidatorSrcAddress) + if err != nil { + panic(err) + } + dst, err := sdk.ValAddressFromBech32(red.ValidatorDstAddress) + if err != nil { + panic(err) + } + + store := f.stakingStore.OpenKVStore(f.ctx) + bz := stakingtypes.MustMarshalRED(f.cdc, red) + if err := store.Set(stakingtypes.GetREDKey(delegator, src, dst), bz); err != nil { + panic(err) + } + if err := store.Set(stakingtypes.GetREDByValSrcIndexKey(delegator, src, dst), []byte{}); err != nil { + panic(err) + } + if err := store.Set(stakingtypes.GetREDByValDstIndexKey(delegator, src, dst), []byte{}); err != nil { + panic(err) + } +} + +func (f *mockFixture) writeRedelegationIndexes(delegator sdk.AccAddress, src, dst sdk.ValAddress) { + store := f.stakingStore.OpenKVStore(f.ctx) + if err := store.Set(stakingtypes.GetREDByValSrcIndexKey(delegator, src, dst), []byte{}); err != nil { + panic(err) + } + if err := store.Set(stakingtypes.GetREDByValDstIndexKey(delegator, src, dst), []byte{}); err != nil { + panic(err) + } +} + +func (f *mockFixture) writeValidatorHistoricalRewards( + val sdk.ValAddress, + period uint64, + rewards distrtypes.ValidatorHistoricalRewards, +) { + store := f.distributionStore.OpenKVStore(f.ctx) + if err := store.Set(distrtypes.GetValidatorHistoricalRewardsKey(val, period), f.cdc.MustMarshal(&rewards)); err != nil { + panic(err) + } +} + +func (f *mockFixture) writeValidatorSlashEvent( + val sdk.ValAddress, + height uint64, + event distrtypes.ValidatorSlashEvent, +) { + store := f.distributionStore.OpenKVStore(f.ctx) + if err := store.Set(distrtypes.GetValidatorSlashEventKey(val, height, event.ValidatorPeriod), f.cdc.MustMarshal(&event)); err != nil { + panic(err) + } +} + func testAccAddr() sdk.AccAddress { return sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) } @@ -1526,6 +1611,175 @@ func TestMigrateValidatorDelegations_WithUnbondingAndRedelegation(t *testing.T) require.NoError(t, err) } +func TestMigrateValidatorDelegations_UsesScopedRedelegationIndexes(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + delegator := testAccAddr() + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + + dstVal := sdk.ValAddress(testAccAddr()) + srcRed := stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dstVal.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 8, + CompletionTime: completionTime, + InitialBalance: math.NewInt(50), + SharesDst: math.LegacyNewDec(50), + UnbondingId: 88, + }}, + } + srcVal := sdk.ValAddress(testAccAddr()) + dstRed := stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: srcVal.String(), + ValidatorDstAddress: oldValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 9, + CompletionTime: completionTime, + InitialBalance: math.NewInt(75), + SharesDst: math.LegacyNewDec(75), + UnbondingId: 89, + }}, + } + unrelatedRed := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: sdk.ValAddress(testAccAddr()).String(), + ValidatorDstAddress: sdk.ValAddress(testAccAddr()).String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 10, + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: 90, + }}, + } + f.writeRedelegation(srcRed) + f.writeRedelegation(dstRed) + f.writeRedelegation(unrelatedRed) + + rewritten := map[uint64]stakingtypes.Redelegation{} + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), gomock.Any()).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, red stakingtypes.Redelegation) error { + require.True(t, red.ValidatorSrcAddress == newValAddr.String() || red.ValidatorDstAddress == newValAddr.String()) + require.False(t, red.ValidatorSrcAddress == oldValAddr.String()) + require.False(t, red.ValidatorDstAddress == oldValAddr.String()) + rewritten[red.Entries[0].UnbondingId] = red + return nil + }, + ).Times(2) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(2) + + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + require.NoError(t, err) + require.Len(t, rewritten, 2) + require.Equal(t, newValAddr.String(), rewritten[88].ValidatorSrcAddress) + require.Equal(t, dstVal.String(), rewritten[88].ValidatorDstAddress) + require.Equal(t, srcVal.String(), rewritten[89].ValidatorSrcAddress) + require.Equal(t, newValAddr.String(), rewritten[89].ValidatorDstAddress) +} + +func TestMigrateValidatorDelegations_DeduplicatesSourceAndDestinationIndexes(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + delegator := testAccAddr() + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + + red := stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: oldValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 10, + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: 101, + }}, + } + f.writeRedelegation(red) + + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), red).Return(nil).Times(1) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, migrated stakingtypes.Redelegation) error { + require.Equal(t, newValAddr.String(), migrated.ValidatorSrcAddress) + require.Equal(t, newValAddr.String(), migrated.ValidatorDstAddress) + return nil + }, + ).Times(1) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(1) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), uint64(101)).Return(nil).Times(1) + + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + require.NoError(t, err) +} + +func TestMigrateValidatorDelegations_ReturnsErrorForStaleRedelegationIndex(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + delegator := testAccAddr() + dstValAddr := sdk.ValAddress(testAccAddr()) + + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + + f.writeRedelegationIndexes(delegator, oldValAddr, dstValAddr) + + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + require.Error(t, err) + require.Contains(t, err.Error(), "points to missing record") +} + +func TestMigrateValidatorDistribution_UsesScopedDistributionPrefixes(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + otherValAddr := sdk.ValAddress(testAccAddr()) + + oldRewards := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 7} + otherRewards := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 99} + oldSlash := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 11, Fraction: math.LegacyMustNewDecFromStr("0.010000000000000000")} + otherSlash := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 22, Fraction: math.LegacyMustNewDecFromStr("0.020000000000000000")} + + f.writeValidatorHistoricalRewards(oldValAddr, 11, oldRewards) + f.writeValidatorHistoricalRewards(otherValAddr, 22, otherRewards) + f.writeValidatorSlashEvent(oldValAddr, 100, oldSlash) + f.writeValidatorSlashEvent(otherValAddr, 200, otherSlash) + + notFound := errors.New("not found") + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorCurrentRewards{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorAccumulatedCommission(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorAccumulatedCommission{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorOutstandingRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorOutstandingRewards{}, notFound) + + f.distributionKeeper.EXPECT().DeleteValidatorHistoricalRewards(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(11), oldRewards).Return(nil) + f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorSlashEvent(gomock.Any(), newValAddr, uint64(100), oldSlash.ValidatorPeriod, oldSlash).Return(nil) + + err := f.keeper.MigrateValidatorDistribution(f.ctx, oldValAddr, newValAddr) + require.NoError(t, err) +} + // --- Validator-supernode metrics tests --- // TestMigrateValidatorSupernode_WithMetrics verifies that metrics state is diff --git a/x/evmigration/keeper/migrate_validator.go b/x/evmigration/keeper/migrate_validator.go index c00cfb39..f681240e 100644 --- a/x/evmigration/keeper/migrate_validator.go +++ b/x/evmigration/keeper/migrate_validator.go @@ -154,13 +154,8 @@ func (k Keeper) MigrateValidatorDelegations(ctx sdk.Context, oldValAddr, newValA // Re-key redelegations where oldValAddr appears as either source or // destination validator. Existing in-flight redelegations must continue to // point at the migrated validator record after operator migration. - var reds []stakingtypes.Redelegation - if err := k.stakingKeeper.IterateRedelegations(ctx, func(_ int64, red stakingtypes.Redelegation) bool { - if red.ValidatorSrcAddress == oldValAddr.String() || red.ValidatorDstAddress == oldValAddr.String() { - reds = append(reds, red) - } - return false - }); err != nil { + reds, err := k.redelegationsForValidator(ctx, oldValAddr) + if err != nil { return err } @@ -236,17 +231,10 @@ func (k Keeper) MigrateValidatorDistribution(ctx sdk.Context, oldValAddr, newVal } // ValidatorHistoricalRewards — collect all periods for oldValAddr, then re-key. - type historicalEntry struct { - period uint64 - rewards distrtypes.ValidatorHistoricalRewards + historicalRewards, err := k.validatorHistoricalRewards(ctx, oldValAddr) + if err != nil { + return err } - var historicalRewards []historicalEntry - k.distributionKeeper.IterateValidatorHistoricalRewards(ctx, func(val sdk.ValAddress, period uint64, rewards distrtypes.ValidatorHistoricalRewards) (stop bool) { - if val.Equals(oldValAddr) { - historicalRewards = append(historicalRewards, historicalEntry{period, rewards}) - } - return false - }) k.distributionKeeper.DeleteValidatorHistoricalRewards(ctx, oldValAddr) for _, hr := range historicalRewards { if err := k.distributionKeeper.SetValidatorHistoricalRewards(ctx, newValAddr, hr.period, hr.rewards); err != nil { @@ -255,17 +243,10 @@ func (k Keeper) MigrateValidatorDistribution(ctx sdk.Context, oldValAddr, newVal } // ValidatorSlashEvents — collect all for oldValAddr, then re-key. - type slashEntry struct { - height uint64 - event distrtypes.ValidatorSlashEvent + slashEvents, err := k.validatorSlashEvents(ctx, oldValAddr) + if err != nil { + return err } - var slashEvents []slashEntry - k.distributionKeeper.IterateValidatorSlashEvents(ctx, func(val sdk.ValAddress, height uint64, event distrtypes.ValidatorSlashEvent) (stop bool) { - if val.Equals(oldValAddr) { - slashEvents = append(slashEvents, slashEntry{height, event}) - } - return false - }) k.distributionKeeper.DeleteValidatorSlashEvents(ctx, oldValAddr) for _, se := range slashEvents { if err := k.distributionKeeper.SetValidatorSlashEvent(ctx, newValAddr, se.height, se.event.ValidatorPeriod, se.event); err != nil { diff --git a/x/evmigration/keeper/migrate_validator_scoped.go b/x/evmigration/keeper/migrate_validator_scoped.go new file mode 100644 index 00000000..b4ce5afd --- /dev/null +++ b/x/evmigration/keeper/migrate_validator_scoped.go @@ -0,0 +1,141 @@ +package keeper + +import ( + "fmt" + + corestore "cosmossdk.io/core/store" + storetypes "cosmossdk.io/store/types" + sdk "github.com/cosmos/cosmos-sdk/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" +) + +type historicalRewardsEntry struct { + period uint64 + rewards distrtypes.ValidatorHistoricalRewards +} + +type slashEventEntry struct { + height uint64 + event distrtypes.ValidatorSlashEvent +} + +func (k Keeper) redelegationsForValidator(ctx sdk.Context, valAddr sdk.ValAddress) ([]stakingtypes.Redelegation, error) { + if k.stakingStoreHandle == nil || k.stakingStoreHandle.svc == nil { + var reds []stakingtypes.Redelegation + if err := k.stakingKeeper.IterateRedelegations(ctx, func(_ int64, red stakingtypes.Redelegation) bool { + if red.ValidatorSrcAddress == valAddr.String() || red.ValidatorDstAddress == valAddr.String() { + reds = append(reds, red) + } + return false + }); err != nil { + return nil, err + } + return reds, nil + } + + store := k.stakingStoreHandle.svc.OpenKVStore(ctx) + seen := make(map[string]stakingtypes.Redelegation) + if err := k.collectRedelegationsByIndex(store, stakingtypes.GetREDsFromValSrcIndexKey(valAddr), stakingtypes.GetREDKeyFromValSrcIndexKey, seen); err != nil { + return nil, err + } + if err := k.collectRedelegationsByIndex(store, stakingtypes.GetREDsToValDstIndexKey(valAddr), stakingtypes.GetREDKeyFromValDstIndexKey, seen); err != nil { + return nil, err + } + + reds := make([]stakingtypes.Redelegation, 0, len(seen)) + for _, red := range seen { + reds = append(reds, red) + } + return reds, nil +} + +func (k Keeper) collectRedelegationsByIndex( + store corestore.KVStore, + prefix []byte, + indexToRedelegationKey func([]byte) []byte, + out map[string]stakingtypes.Redelegation, +) error { + iterator, err := store.Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + if err != nil { + return err + } + defer iterator.Close() + + for ; iterator.Valid(); iterator.Next() { + redKey := indexToRedelegationKey(iterator.Key()) + bz, err := store.Get(redKey) + if err != nil { + return err + } + if bz == nil { + return fmt.Errorf("redelegation index %X points to missing record %X", iterator.Key(), redKey) + } + red, err := stakingtypes.UnmarshalRED(k.cdc, bz) + if err != nil { + return err + } + out[string(redKey)] = red + } + return nil +} + +func (k Keeper) validatorHistoricalRewards(ctx sdk.Context, valAddr sdk.ValAddress) ([]historicalRewardsEntry, error) { + if k.distributionStoreHandle == nil || k.distributionStoreHandle.svc == nil { + var entries []historicalRewardsEntry + k.distributionKeeper.IterateValidatorHistoricalRewards(ctx, func(val sdk.ValAddress, period uint64, rewards distrtypes.ValidatorHistoricalRewards) bool { + if val.Equals(valAddr) { + entries = append(entries, historicalRewardsEntry{period: period, rewards: rewards}) + } + return false + }) + return entries, nil + } + + store := k.distributionStoreHandle.svc.OpenKVStore(ctx) + prefix := distrtypes.GetValidatorHistoricalRewardsPrefix(valAddr) + iterator, err := store.Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + if err != nil { + return nil, err + } + defer iterator.Close() + + var entries []historicalRewardsEntry + for ; iterator.Valid(); iterator.Next() { + var rewards distrtypes.ValidatorHistoricalRewards + k.cdc.MustUnmarshal(iterator.Value(), &rewards) + _, period := distrtypes.GetValidatorHistoricalRewardsAddressPeriod(iterator.Key()) + entries = append(entries, historicalRewardsEntry{period: period, rewards: rewards}) + } + return entries, nil +} + +func (k Keeper) validatorSlashEvents(ctx sdk.Context, valAddr sdk.ValAddress) ([]slashEventEntry, error) { + if k.distributionStoreHandle == nil || k.distributionStoreHandle.svc == nil { + var entries []slashEventEntry + k.distributionKeeper.IterateValidatorSlashEvents(ctx, func(val sdk.ValAddress, height uint64, event distrtypes.ValidatorSlashEvent) bool { + if val.Equals(valAddr) { + entries = append(entries, slashEventEntry{height: height, event: event}) + } + return false + }) + return entries, nil + } + + store := k.distributionStoreHandle.svc.OpenKVStore(ctx) + prefix := distrtypes.GetValidatorSlashEventPrefix(valAddr) + iterator, err := store.Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + if err != nil { + return nil, err + } + defer iterator.Close() + + var entries []slashEventEntry + for ; iterator.Valid(); iterator.Next() { + var event distrtypes.ValidatorSlashEvent + k.cdc.MustUnmarshal(iterator.Value(), &event) + _, height := distrtypes.GetValidatorSlashEventAddressHeight(iterator.Key()) + entries = append(entries, slashEventEntry{height: height, event: event}) + } + return entries, nil +} diff --git a/x/evmigration/keeper/msg_server_claim_legacy_test.go b/x/evmigration/keeper/msg_server_claim_legacy_test.go index a0cbb0a5..2830a5a1 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -111,9 +111,17 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModule{}) addrCodec := addresscodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) storeKey := storetypes.NewKVStoreKey(types.StoreKey) + stakingStoreKey := storetypes.NewKVStoreKey(stakingtypes.StoreKey) storeService := runtime.NewKVStoreService(storeKey) - ctx := testutil.DefaultContextWithDB(t, storeKey, storetypes.NewTransientStoreKey("transient_test")).Ctx. - WithChainID(testChainID) + stakingStoreService := runtime.NewKVStoreService(stakingStoreKey) + ctx := testutil.DefaultContextWithKeys( + map[string]*storetypes.KVStoreKey{ + types.StoreKey: storeKey, + stakingtypes.StoreKey: stakingStoreKey, + }, + map[string]*storetypes.TransientStoreKey{"transient_test": storetypes.NewTransientStoreKey("transient_test")}, + nil, + ).WithChainID(testChainID).WithBlockTime(time.Now()) authority := authtypes.NewModuleAddress(types.GovModuleName) @@ -133,10 +141,10 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { claimKeeper, ) - // Wire a dummy staking store service so DeleteValidatorRecordNoHooks can run - // in unit tests (the store itself is isolated and any delete is a safe no-op - // against non-existent keys). Production wiring happens in app.go. - k.SetStakingStoreService(storeService) + // Wire an isolated staking store service so scoped redelegation iteration + // and DeleteValidatorRecordNoHooks can run in unit tests. Production wiring + // happens in app.go. + k.SetStakingStoreService(stakingStoreService) // Initialize params with migration enabled. params := types.NewParams(true, 0, 50, 2000, 20) @@ -147,6 +155,8 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { mf := &mockFixture{ ctx: ctx, keeper: k, + cdc: encCfg.Codec, + stakingStore: stakingStoreService, accountKeeper: accountKeeper, bankKeeper: bankKeeper, stakingKeeper: stakingKeeper, @@ -936,7 +946,6 @@ func setupPassingValPreChecks(t *testing.T, f *msgServerFixture) ( // No delegations/ubds/reds (under limit). f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) msg := newValidatorMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) @@ -995,7 +1004,6 @@ func setupV1toV4(f *mockFixture, oldValAddr, newValAddr sdk.ValAddress) { // V4: no delegations. f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) } // TestMigrateValidator_FailAtValidatorRecord verifies that a failure in diff --git a/x/evmigration/keeper/msg_server_migrate_validator.go b/x/evmigration/keeper/msg_server_migrate_validator.go index 9f209f78..9e2d69a1 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator.go +++ b/x/evmigration/keeper/msg_server_migrate_validator.go @@ -87,16 +87,11 @@ func (ms msgServer) MigrateValidator(goCtx context.Context, msg *types.MsgMigrat // Count redelegations where the validator appears as EITHER source or // destination. The execution path (MigrateValidatorDelegations) re-keys // both directions, so the safety bound must account for both. - var redCount int - if err := ms.stakingKeeper.IterateRedelegations(ctx, func(_ int64, red stakingtypes.Redelegation) bool { - if red.ValidatorSrcAddress == oldValAddr.String() || red.ValidatorDstAddress == oldValAddr.String() { - redCount++ - } - return false - }); err != nil { + reds, err := ms.redelegationsForValidator(ctx, oldValAddr) + if err != nil { return nil, err } - totalRecords := uint64(len(delegations) + len(ubds) + redCount) + totalRecords := uint64(len(delegations) + len(ubds) + len(reds)) if totalRecords > params.MaxValidatorDelegations { return nil, types.ErrTooManyDelegators.Wrapf( "total records %d exceeds max %d", totalRecords, params.MaxValidatorDelegations, diff --git a/x/evmigration/keeper/msg_server_migrate_validator_test.go b/x/evmigration/keeper/msg_server_migrate_validator_test.go index 169139c4..28e32a90 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator_test.go +++ b/x/evmigration/keeper/msg_server_migrate_validator_test.go @@ -134,7 +134,62 @@ func TestMigrateValidator_TooManyDelegators(t *testing.T) { }, nil, ) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) + + msg := newValidatorMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) + + _, err := f.msgServer.MigrateValidator(f.ctx, msg) + require.ErrorIs(t, err, types.ErrTooManyDelegators) +} + +func TestMigrateValidator_TooManyDelegatorsIncludesScopedRedelegations(t *testing.T) { + f := initMsgServerFixture(t) + + params := types.NewParams(true, 0, 50, 1, 20) + require.NoError(t, f.keeper.Params.Set(f.ctx, params)) + + privKey := secp256k1.GenPrivKey() + legacyAddr := sdk.AccAddress(privKey.PubKey().Address()) + newPrivKey, newAddr := testNewMigrationAccount(t) + oldValAddr := sdk.ValAddress(legacyAddr) + newValAddr := sdk.ValAddress(newAddr) + + baseAcc := authtypes.NewBaseAccountWithAddress(legacyAddr) + f.accountKeeper.EXPECT().GetAccount(gomock.Any(), legacyAddr).Return(baseAcc) + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), oldValAddr).Return( + stakingtypes.Validator{OperatorAddress: oldValAddr.String(), Status: stakingtypes.Bonded}, nil, + ) + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return( + stakingtypes.Validator{}, stakingtypes.ErrNoValidatorFound, + ) + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + delegator := testAccAddr() + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: sdk.ValAddress(testAccAddr()).String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 10, + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: 201, + }}, + }) + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: sdk.ValAddress(testAccAddr()).String(), + ValidatorDstAddress: oldValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 11, + CompletionTime: completionTime, + InitialBalance: math.NewInt(200), + SharesDst: math.LegacyNewDec(200), + UnbondingId: 202, + }}, + }) msg := newValidatorMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) @@ -177,7 +232,6 @@ func TestMigrateValidator_Success(t *testing.T) { []stakingtypes.Delegation{del}, nil, ) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // Step V1: Withdraw commission and delegation rewards. f.distributionKeeper.EXPECT().WithdrawValidatorCommission(gomock.Any(), oldValAddr).Return(sdk.Coins{}, nil) @@ -244,7 +298,6 @@ func TestMigrateValidator_Success(t *testing.T) { f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, legacyAddr, gomock.Any()).Return(nil) // No unbonding delegations or redelegations. f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // Step V5: MigrateValidatorSupernode — not a supernode. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) @@ -354,7 +407,6 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { []stakingtypes.Delegation{selfDel}, nil, ) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // Step V1: Withdraw commission + self-delegation rewards. f.distributionKeeper.EXPECT().WithdrawValidatorCommission(gomock.Any(), oldValAddr).Return(sdk.Coins{}, nil) @@ -410,7 +462,6 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, legacyAddr, gomock.Any()).Return(nil) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // V5: no supernode. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) @@ -550,7 +601,6 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { // Called twice: once for pre-check count, once inside MigrateValidatorDelegations. f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(allDels, nil).Times(2) f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil).Times(2) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // Step V1: Withdraw commission. f.distributionKeeper.EXPECT().WithdrawValidatorCommission(gomock.Any(), oldValAddr).Return(sdk.Coins{}, nil) @@ -618,7 +668,6 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { } // Redelegation re-keying — none. - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // Supernode — not found. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) diff --git a/x/evmigration/keeper/query.go b/x/evmigration/keeper/query.go index 777f5527..f3cfbc6c 100644 --- a/x/evmigration/keeper/query.go +++ b/x/evmigration/keeper/query.go @@ -181,14 +181,9 @@ func (qs queryServer) MigrationEstimate(goCtx context.Context, req *types.QueryM } // Count redelegations where the validator is source OR destination // (both are re-keyed during migration). - var redCount uint64 - _ = qs.k.stakingKeeper.IterateRedelegations(ctx, func(_ int64, red stakingtypes.Redelegation) bool { - if red.ValidatorSrcAddress == valAddr.String() || red.ValidatorDstAddress == valAddr.String() { - redCount++ - } - return false - }) - resp.ValRedelegationCount = redCount + if reds, err := qs.k.redelegationsForValidator(ctx, valAddr); err == nil { + resp.ValRedelegationCount = uint64(len(reds)) + } // Surface the validator's BondStatus + Jailed flag so callers can // display the actionable cause when WouldSucceed is false. Jailed diff --git a/x/evmigration/keeper/query_test.go b/x/evmigration/keeper/query_test.go index 55a7fa5d..54d857a5 100644 --- a/x/evmigration/keeper/query_test.go +++ b/x/evmigration/keeper/query_test.go @@ -312,6 +312,87 @@ func TestQueryMigrationEstimate_AlreadyMigrated(t *testing.T) { require.False(t, resp.HasSupernode) } +func TestQueryMigrationEstimate_ValidatorUsesScopedRedelegationIndexesForLimit(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + qs := keeper.NewQueryServerImpl(f.keeper) + + params := types.NewParams(true, 0, 50, 2, 20) + require.NoError(t, f.keeper.Params.Set(f.ctx, params)) + + addr := testAccAddr() + valAddr := sdk.ValAddress(addr) + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + delegator := testAccAddr() + + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: valAddr.String(), + ValidatorDstAddress: sdk.ValAddress(testAccAddr()).String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 10, + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: 301, + }}, + }) + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: delegator.String(), + ValidatorSrcAddress: sdk.ValAddress(testAccAddr()).String(), + ValidatorDstAddress: valAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 11, + CompletionTime: completionTime, + InitialBalance: math.NewInt(200), + SharesDst: math.LegacyNewDec(200), + UnbondingId: 302, + }}, + }) + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: sdk.ValAddress(testAccAddr()).String(), + ValidatorDstAddress: sdk.ValAddress(testAccAddr()).String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 12, + CompletionTime: completionTime, + InitialBalance: math.NewInt(300), + SharesDst: math.LegacyNewDec(300), + UnbondingId: 303, + }}, + }) + + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{OperatorAddress: valAddr.String(), Status: stakingtypes.Bonded}, nil, + ) + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), valAddr).Return( + []stakingtypes.Delegation{ + stakingtypes.NewDelegation(delegator.String(), valAddr.String(), math.LegacyNewDec(50)), + }, nil, + ) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), valAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetDelegatorDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetRedelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) + f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), addr).Return(sdk.Coins{}) + f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), valAddr).Return(sntypes.SuperNode{}, false) + f.accountKeeper.EXPECT().GetAccount(gomock.Any(), addr).Return(nil) + + resp, err := qs.MigrationEstimate(f.ctx, &types.QueryMigrationEstimateRequest{ + LegacyAddress: addr.String(), + }) + require.NoError(t, err) + require.True(t, resp.IsValidator) + require.Equal(t, uint64(1), resp.ValDelegationCount) + require.Equal(t, uint64(2), resp.ValRedelegationCount) + require.Equal(t, uint64(3), resp.TotalTouched) + require.False(t, resp.WouldSucceed) + require.Equal(t, "too many delegators", resp.RejectionReason) +} + // --- LegacyAccounts query tests --- // TestQueryLegacyAccounts_WithSecp256k1 verifies that accounts with secp256k1 From 0836de58051bfceff596e6c053ecd8a9cc172ae3 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 08:05:39 -0400 Subject: [PATCH 03/28] evmigration: cut double-fetch and O(H) refcount scans in validator migration Two hot-path optimizations for MigrateValidatorDelegations, layered on the scoped-iteration work already on this branch: - Thread the delegations/unbondings/redelegations already read by the MaxValidatorDelegations pre-check into MigrateValidatorDelegations rather than re-reading them. This removes a second O(N) scan of the same staking records (plus a second validator-scoped redelegation index scan) on the migration hot path, which dominated block time for validators with many delegations. Passing nil redelegations still triggers the internal scan so tests can exercise it directly. - Replace the reset-to-1 + increment-per-delegation refcount pattern with a single setHistoricalRewardsReferenceCount write of base(1)+len(delegations). All re-keyed delegations share one historical-rewards period, so the count is deterministic. New validatorHistoricalReward does an O(1) keyed store.Get of a single (validator, period) row; adjustHistoricalRewardsReferenceCount now uses it too. Eliminates the N+1 full-chain IterateValidatorHistoricalRewards scans the old path incurred per migrated validator. Co-Authored-By: Claude Opus 4.7 --- x/evmigration/keeper/migrate_distribution.go | 47 +- x/evmigration/keeper/migrate_test.go | 404 +++++++++++++++++- x/evmigration/keeper/migrate_validator.go | 52 ++- .../keeper/migrate_validator_scoped.go | 42 +- .../keeper/msg_server_claim_legacy_test.go | 32 +- .../keeper/msg_server_migrate_validator.go | 6 +- .../msg_server_migrate_validator_test.go | 41 +- 7 files changed, 505 insertions(+), 119 deletions(-) diff --git a/x/evmigration/keeper/migrate_distribution.go b/x/evmigration/keeper/migrate_distribution.go index 4adfe453..d64d6f3e 100644 --- a/x/evmigration/keeper/migrate_distribution.go +++ b/x/evmigration/keeper/migrate_distribution.go @@ -4,7 +4,6 @@ import ( "fmt" sdk "github.com/cosmos/cosmos-sdk/types" - distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" ) // MigrateDistribution withdraws all pending delegation rewards for legacyAddr, @@ -109,46 +108,28 @@ func (k Keeper) incrementHistoricalRewardsReferenceCount(ctx sdk.Context, valAdd return k.adjustHistoricalRewardsReferenceCount(ctx, valAddr, period, 1, false) } -// resetHistoricalRewardsReferenceCount sets the reference count to 1 (base only), -// clearing stale delegator references before re-creating delegations. -func (k Keeper) resetHistoricalRewardsReferenceCount(ctx sdk.Context, valAddr sdk.ValAddress, period uint64) error { - var ( - found bool - historical distrtypes.ValidatorHistoricalRewards - ) - - k.distributionKeeper.IterateValidatorHistoricalRewards(ctx, func(val sdk.ValAddress, p uint64, rewards distrtypes.ValidatorHistoricalRewards) (stop bool) { - if val.Equals(valAddr) && p == period { - found = true - historical = rewards - return true - } - return false - }) - +// setHistoricalRewardsReferenceCount overwrites the reference count of a single +// (valAddr, period) historical rewards row. Used to set the count in one write +// instead of a reset-then-increment-per-delegation loop, clearing stale +// delegator references in the process. +func (k Keeper) setHistoricalRewardsReferenceCount(ctx sdk.Context, valAddr sdk.ValAddress, period uint64, count uint32) error { + historical, found, err := k.validatorHistoricalReward(ctx, valAddr, period) + if err != nil { + return err + } if !found { return fmt.Errorf("validator historical rewards not found for %s period %d", valAddr.String(), period) } - historical.ReferenceCount = 1 + historical.ReferenceCount = count return k.distributionKeeper.SetValidatorHistoricalRewards(ctx, valAddr, period, historical) } func (k Keeper) adjustHistoricalRewardsReferenceCount(ctx sdk.Context, valAddr sdk.ValAddress, period uint64, delta int64, repairZero bool) error { - var ( - found bool - historical distrtypes.ValidatorHistoricalRewards - ) - - k.distributionKeeper.IterateValidatorHistoricalRewards(ctx, func(val sdk.ValAddress, p uint64, rewards distrtypes.ValidatorHistoricalRewards) (stop bool) { - if val.Equals(valAddr) && p == period { - found = true - historical = rewards - return true - } - return false - }) - + historical, found, err := k.validatorHistoricalReward(ctx, valAddr, period) + if err != nil { + return err + } if !found { return fmt.Errorf("validator historical rewards not found for %s period %d", valAddr.String(), period) } diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index 5fc82d87..b7f8b530 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -193,6 +193,21 @@ func (f *mockFixture) writeValidatorSlashEvent( } } +func countKVPrefix(t *testing.T, storeService corestore.KVStoreService, ctx sdk.Context, prefix []byte) int { + t.Helper() + + store := storeService.OpenKVStore(ctx) + iterator, err := store.Iterator(prefix, storetypes.PrefixEndBytes(prefix)) + require.NoError(t, err) + defer func() { _ = iterator.Close() }() + + var count int + for ; iterator.Valid(); iterator.Next() { + count++ + } + return count +} + func testAccAddr() sdk.AccAddress { return sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) } @@ -219,9 +234,10 @@ func expectHistoricalRewardsIncrement( mock.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), val, period, gomock.Any()).Return(nil) } -// expectHistoricalRewardsReset sets up mock expectations for -// resetHistoricalRewardsReferenceCount: iterate to find the period, then set refcount to 1. -func expectHistoricalRewardsReset( +// expectHistoricalRewardsSet sets up mock expectations for +// setHistoricalRewardsReferenceCount: look up the (val, period) row, then write +// its refcount back in a single set. +func expectHistoricalRewardsSet( mock *evmigrationmocks.MockDistributionKeeper, val sdk.ValAddress, period uint64, @@ -1517,8 +1533,7 @@ func TestMigrateValidatorDelegations_WithUnbondingAndRedelegation(t *testing.T) completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) - // No active delegations. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) + // No active delegations (passed in directly, not fetched). // One unbonding delegation with an UnbondingId. ubd := stakingtypes.UnbondingDelegation{ @@ -1534,9 +1549,6 @@ func TestMigrateValidatorDelegations_WithUnbondingAndRedelegation(t *testing.T) }, }, } - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return( - []stakingtypes.UnbondingDelegation{ubd}, nil, - ) f.stakingKeeper.EXPECT().RemoveUnbondingDelegation(gomock.Any(), ubd).Return(nil) f.stakingKeeper.EXPECT().SetUnbondingDelegation(gomock.Any(), gomock.Any()).DoAndReturn( func(_ any, newUbd stakingtypes.UnbondingDelegation) error { @@ -1579,6 +1591,8 @@ func TestMigrateValidatorDelegations_WithUnbondingAndRedelegation(t *testing.T) }, }, } + // Redelegations are discovered by an internal scan; this fixture leaves the + // scoped store unwired, so the scan falls back to IterateRedelegations. f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).DoAndReturn( func(_ any, fn func(int64, stakingtypes.Redelegation) bool) error { require.False(t, fn(0, srcRed)) @@ -1607,7 +1621,12 @@ func TestMigrateValidatorDelegations_WithUnbondingAndRedelegation(t *testing.T) f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil) f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), uint64(89)).Return(nil) - err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + err := f.keeper.MigrateValidatorDelegations( + f.ctx, oldValAddr, newValAddr, + nil, + []stakingtypes.UnbondingDelegation{ubd}, + nil, + ) require.NoError(t, err) } @@ -1620,9 +1639,6 @@ func TestMigrateValidatorDelegations_UsesScopedRedelegationIndexes(t *testing.T) delegator := testAccAddr() completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - dstVal := sdk.ValAddress(testAccAddr()) srcRed := stakingtypes.Redelegation{ DelegatorAddress: delegator.String(), @@ -1679,7 +1695,9 @@ func TestMigrateValidatorDelegations_UsesScopedRedelegationIndexes(t *testing.T) f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(2) f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(2) - err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + // V4's internal scoped scan discovers the two related redelegations + // (dropping the unrelated one) and re-keys exactly those. + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, nil) require.NoError(t, err) require.Len(t, rewritten, 2) require.Equal(t, newValAddr.String(), rewritten[88].ValidatorSrcAddress) @@ -1697,9 +1715,6 @@ func TestMigrateValidatorDelegations_DeduplicatesSourceAndDestinationIndexes(t * delegator := testAccAddr() completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - red := stakingtypes.Redelegation{ DelegatorAddress: delegator.String(), ValidatorSrcAddress: oldValAddr.String(), @@ -1725,7 +1740,48 @@ func TestMigrateValidatorDelegations_DeduplicatesSourceAndDestinationIndexes(t * f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(1) f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), uint64(101)).Return(nil).Times(1) - err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + // V4's internal scan collects the doubly-indexed redelegation exactly once + // (src == dst), so it is re-keyed a single time (the .Times(1) mocks above). + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, nil) + require.NoError(t, err) +} + +func TestMigrateValidatorDelegations_UsesPreloadedRedelegations(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + dstValAddr := sdk.ValAddress(testAccAddr()) + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + red := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dstValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 10, + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: 111, + }}, + } + + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), red).Return(nil).Times(1) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ sdk.Context, migrated stakingtypes.Redelegation) error { + require.Equal(t, newValAddr.String(), migrated.ValidatorSrcAddress) + require.Equal(t, dstValAddr.String(), migrated.ValidatorDstAddress) + return nil + }, + ).Times(1) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(1) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), uint64(111)).Return(nil).Times(1) + + // The staking store intentionally has no redelegation rows. Passing a + // non-nil preloaded slice proves V4 does not rescan the scoped indexes. + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, []stakingtypes.Redelegation{red}) require.NoError(t, err) } @@ -1738,12 +1794,12 @@ func TestMigrateValidatorDelegations_ReturnsErrorForStaleRedelegationIndex(t *te delegator := testAccAddr() dstValAddr := sdk.ValAddress(testAccAddr()) - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) - f.writeRedelegationIndexes(delegator, oldValAddr, dstValAddr) - err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr) + // A stale redelegation index (no backing record) surfaces from V4's internal + // scoped scan, which aborts before re-keying anything. delegations/ubds are + // nil, so the scan runs first and its error propagates unchanged. + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), "points to missing record") } @@ -1780,6 +1836,312 @@ func TestMigrateValidatorDistribution_UsesScopedDistributionPrefixes(t *testing. require.NoError(t, err) } +func TestMigrateValidatorDelegations_RekeysMultipleSourceRedelegations(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + // Two redelegations that both have oldValAddr as SOURCE, from distinct + // delegators to distinct destinations. Both live under the same val-src + // index prefix, so the scan must advance the iterator past the first key. + dstA := sdk.ValAddress(testAccAddr()) + dstB := sdk.ValAddress(testAccAddr()) + redA := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dstA.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 8, + CompletionTime: completionTime, + InitialBalance: math.NewInt(50), + SharesDst: math.LegacyNewDec(50), + UnbondingId: 201, + }}, + } + redB := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dstB.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 9, + CompletionTime: completionTime, + InitialBalance: math.NewInt(75), + SharesDst: math.LegacyNewDec(75), + UnbondingId: 202, + }}, + } + f.writeRedelegation(redA) + f.writeRedelegation(redB) + + rewritten := map[uint64]stakingtypes.Redelegation{} + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), gomock.Any()).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, red stakingtypes.Redelegation) error { + require.Equal(t, newValAddr.String(), red.ValidatorSrcAddress) + require.NotEqual(t, oldValAddr.String(), red.ValidatorDstAddress) + rewritten[red.Entries[0].UnbondingId] = red + return nil + }, + ).Times(2) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(2) + + // Both redelegations share the val-src index prefix; V4's internal scan must + // advance the iterator past the first key to collect (and re-key) both. + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, nil) + require.NoError(t, err) + require.Len(t, rewritten, 2) + require.Equal(t, dstA.String(), rewritten[201].ValidatorDstAddress) + require.Equal(t, dstB.String(), rewritten[202].ValidatorDstAddress) +} + +// TestMigrateValidatorDelegations_SetsHistoricalRewardsRefCountOnce pins the V4 +// optimization: rather than resetting the target period's historical-rewards +// reference count and then incrementing it once per delegation (N+1 full-chain +// scans), the count is written exactly once as base(1) + N. The scoped +// distribution store is wired, so the lookup is the production O(1) store.Get +// path; the single write is captured to assert the exact final count. A wrong +// count (off-by-one) or a per-delegation-increment regression (Times(1) → N) +// both fail here. +func TestMigrateValidatorDelegations_SetsHistoricalRewardsRefCountOnce(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + + // Three active delegations from distinct delegators. + dels := make([]stakingtypes.Delegation, 3) + for i := range dels { + dels[i] = stakingtypes.NewDelegation(testAccAddr().String(), oldValAddr.String(), math.LegacyNewDec(int64(10*(i+1)))) + } + + // Current rewards period 5 → target (previous) period 4. + const targetPeriod = uint64(4) + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), newValAddr).Return( + distrtypes.ValidatorCurrentRewards{Period: 5}, nil, + ) + // Seed a stale base row so the scoped O(1) lookup succeeds; its count must be + // overwritten in a single write, not incremented from. + f.writeValidatorHistoricalRewards(newValAddr, targetPeriod, distrtypes.ValidatorHistoricalRewards{ReferenceCount: 1}) + + // The refcount write must happen EXACTLY once; a per-delegation increment + // regression would call it N times and fail the Times(1) bound. + var capturedRefCount uint32 + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).DoAndReturn( + func(_ sdk.Context, _ sdk.ValAddress, _ uint64, h distrtypes.ValidatorHistoricalRewards) error { + capturedRefCount = h.ReferenceCount + return nil + }, + ).Times(1) + + // Per-delegation re-keying: no refcount call inside the loop. Each delegation's + // fresh starting info must reference the target period. + f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, gomock.Any()).Return(nil).Times(3) + f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), gomock.Any()).Return(nil).Times(3) + f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil).Times(3) + f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ sdk.Context, _ sdk.ValAddress, _ sdk.AccAddress, info distrtypes.DelegatorStartingInfo) error { + require.Equal(t, targetPeriod, info.PreviousPeriod) + return nil + }, + ).Times(3) + + // Delegations passed in directly; no unbondings/redelegations. + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, dels, nil, nil) + require.NoError(t, err) + // base(1) + 3 delegations. + require.Equal(t, uint32(4), capturedRefCount) +} + +func TestMigrateValidatorDistribution_RekeysAllPeriodsAndSlashEvents(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + otherValAddr := sdk.ValAddress(testAccAddr()) + + // Two historical-reward periods for old, one for an unrelated validator. + // Distinct ReferenceCounts make the two re-key calls distinguishable. + rewards11 := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 7} + rewards42 := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 13} + otherRewards := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 99} + f.writeValidatorHistoricalRewards(oldValAddr, 11, rewards11) + f.writeValidatorHistoricalRewards(oldValAddr, 42, rewards42) + f.writeValidatorHistoricalRewards(otherValAddr, 11, otherRewards) + + // Two slash events for old with DISTINCT heights AND periods (height!=period + // per event), plus one for an unrelated validator. Height comes from the + // store key, period from the unmarshaled value; making them differ pins down + // that the re-key does not swap the two. + slash100 := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 5, Fraction: math.LegacyMustNewDecFromStr("0.010000000000000000")} + slash250 := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 9, Fraction: math.LegacyMustNewDecFromStr("0.030000000000000000")} + otherSlash := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 22, Fraction: math.LegacyMustNewDecFromStr("0.020000000000000000")} + f.writeValidatorSlashEvent(oldValAddr, 100, slash100) + f.writeValidatorSlashEvent(oldValAddr, 250, slash250) + f.writeValidatorSlashEvent(otherValAddr, 300, otherSlash) + + notFound := errors.New("not found") + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorCurrentRewards{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorAccumulatedCommission(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorAccumulatedCommission{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorOutstandingRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorOutstandingRewards{}, notFound) + + f.distributionKeeper.EXPECT().DeleteValidatorHistoricalRewards(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(11), rewards11).Return(nil) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(42), rewards42).Return(nil) + + f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorSlashEvent(gomock.Any(), newValAddr, uint64(100), uint64(5), slash100).Return(nil) + f.distributionKeeper.EXPECT().SetValidatorSlashEvent(gomock.Any(), newValAddr, uint64(250), uint64(9), slash250).Return(nil) + + err := f.keeper.MigrateValidatorDistribution(f.ctx, oldValAddr, newValAddr) + require.NoError(t, err) +} + +func TestMigrateValidatorScopedIteration_SimulatesGlobalStateImprovement(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + const ( + unrelatedValidators = 25 + recordsPerValidator = 20 + ) + for i := 0; i < unrelatedValidators; i++ { + val := sdk.ValAddress(testAccAddr()) + for j := 0; j < recordsPerValidator; j++ { + period := uint64(10_000 + i*recordsPerValidator + j) + height := uint64(20_000 + i*recordsPerValidator + j) + f.writeValidatorHistoricalRewards(val, period, distrtypes.ValidatorHistoricalRewards{ReferenceCount: uint32(1 + j%3)}) + f.writeValidatorSlashEvent( + val, + height, + distrtypes.ValidatorSlashEvent{ + ValidatorPeriod: period, + Fraction: math.LegacyMustNewDecFromStr("0.010000000000000000"), + }, + ) + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: sdk.ValAddress(testAccAddr()).String(), + ValidatorDstAddress: sdk.ValAddress(testAccAddr()).String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: int64(height), + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: uint64(30_000 + i*recordsPerValidator + j), + }}, + }) + } + } + + rewards11 := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 7} + rewards12 := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 8} + rewards13 := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 9} + f.writeValidatorHistoricalRewards(oldValAddr, 11, rewards11) + f.writeValidatorHistoricalRewards(oldValAddr, 12, rewards12) + f.writeValidatorHistoricalRewards(oldValAddr, 13, rewards13) + + slash100 := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 21, Fraction: math.LegacyMustNewDecFromStr("0.020000000000000000")} + slash101 := distrtypes.ValidatorSlashEvent{ValidatorPeriod: 22, Fraction: math.LegacyMustNewDecFromStr("0.030000000000000000")} + f.writeValidatorSlashEvent(oldValAddr, 100, slash100) + f.writeValidatorSlashEvent(oldValAddr, 101, slash101) + + dstValAddr := sdk.ValAddress(testAccAddr()) + srcValAddr := sdk.ValAddress(testAccAddr()) + srcRed := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dstValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 101, + CompletionTime: completionTime, + InitialBalance: math.NewInt(50), + SharesDst: math.LegacyNewDec(50), + UnbondingId: 40_001, + }}, + } + dstRed := stakingtypes.Redelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorSrcAddress: srcValAddr.String(), + ValidatorDstAddress: oldValAddr.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: 102, + CompletionTime: completionTime, + InitialBalance: math.NewInt(75), + SharesDst: math.LegacyNewDec(75), + UnbondingId: 40_002, + }}, + } + f.writeRedelegation(srcRed) + f.writeRedelegation(dstRed) + + broadScanKeys := countKVPrefix(t, f.distributionStore, f.ctx, distrtypes.ValidatorHistoricalRewardsPrefix) + + countKVPrefix(t, f.distributionStore, f.ctx, distrtypes.ValidatorSlashEventPrefix) + + countKVPrefix(t, f.stakingStore, f.ctx, stakingtypes.RedelegationKey) + scopedDistributionKeys := countKVPrefix(t, f.distributionStore, f.ctx, distrtypes.GetValidatorHistoricalRewardsPrefix(oldValAddr)) + + countKVPrefix(t, f.distributionStore, f.ctx, distrtypes.GetValidatorSlashEventPrefix(oldValAddr)) + scopedRedelegationKeys := countKVPrefix(t, f.stakingStore, f.ctx, stakingtypes.GetREDsFromValSrcIndexKey(oldValAddr)) + + countKVPrefix(t, f.stakingStore, f.ctx, stakingtypes.GetREDsToValDstIndexKey(oldValAddr)) + scopedKeysBeforeRedelegationReuse := scopedDistributionKeys + scopedRedelegationKeys + scopedRedelegationKeys + scopedKeysAfterRedelegationReuse := scopedDistributionKeys + scopedRedelegationKeys + + require.Equal(t, 1507, broadScanKeys) + require.Equal(t, 9, scopedKeysBeforeRedelegationReuse) + require.Equal(t, 7, scopedKeysAfterRedelegationReuse) + require.GreaterOrEqual(t, broadScanKeys/scopedKeysAfterRedelegationReuse, 200) + t.Logf( + "simulated testnet shape: old broad scan keys=%d, scoped before red reuse=%d, scoped after red reuse=%d, main reduction=%dx, incremental scoped reduction=%d%%", + broadScanKeys, + scopedKeysBeforeRedelegationReuse, + scopedKeysAfterRedelegationReuse, + broadScanKeys/scopedKeysAfterRedelegationReuse, + 100*(scopedKeysBeforeRedelegationReuse-scopedKeysAfterRedelegationReuse)/scopedKeysBeforeRedelegationReuse, + ) + + notFound := errors.New("not found") + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorCurrentRewards{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorAccumulatedCommission(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorAccumulatedCommission{}, notFound) + f.distributionKeeper.EXPECT().GetValidatorOutstandingRewards(gomock.Any(), oldValAddr).Return(distrtypes.ValidatorOutstandingRewards{}, notFound) + f.distributionKeeper.EXPECT().DeleteValidatorHistoricalRewards(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(11), rewards11).Return(nil) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(12), rewards12).Return(nil) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, uint64(13), rewards13).Return(nil) + f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) + f.distributionKeeper.EXPECT().SetValidatorSlashEvent(gomock.Any(), newValAddr, uint64(100), slash100.ValidatorPeriod, slash100).Return(nil) + f.distributionKeeper.EXPECT().SetValidatorSlashEvent(gomock.Any(), newValAddr, uint64(101), slash101.ValidatorPeriod, slash101).Return(nil) + + err := f.keeper.MigrateValidatorDistribution(f.ctx, oldValAddr, newValAddr) + require.NoError(t, err) + + rewritten := map[uint64]stakingtypes.Redelegation{} + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), gomock.Any()).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ sdk.Context, red stakingtypes.Redelegation) error { + rewritten[red.Entries[0].UnbondingId] = red + return nil + }, + ).Times(2) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(2) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(2) + + err = f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, []stakingtypes.Redelegation{srcRed, dstRed}) + require.NoError(t, err) + require.Len(t, rewritten, 2) + require.Equal(t, newValAddr.String(), rewritten[40_001].ValidatorSrcAddress) + require.Equal(t, dstValAddr.String(), rewritten[40_001].ValidatorDstAddress) + require.Equal(t, srcValAddr.String(), rewritten[40_002].ValidatorSrcAddress) + require.Equal(t, newValAddr.String(), rewritten[40_002].ValidatorDstAddress) +} + // --- Validator-supernode metrics tests --- // TestMigrateValidatorSupernode_WithMetrics verifies that metrics state is diff --git a/x/evmigration/keeper/migrate_validator.go b/x/evmigration/keeper/migrate_validator.go index f681240e..3c4468fa 100644 --- a/x/evmigration/keeper/migrate_validator.go +++ b/x/evmigration/keeper/migrate_validator.go @@ -61,16 +61,25 @@ func (k Keeper) MigrateValidatorRecord(ctx sdk.Context, oldValAddr, newValAddr s // MigrateValidatorDelegations re-keys all delegations pointing to oldValAddr // to point to newValAddr. This affects ALL delegators, not just the operator. -func (k Keeper) MigrateValidatorDelegations(ctx sdk.Context, oldValAddr, newValAddr sdk.ValAddress) error { - // Re-key active delegations. - delegations, err := k.stakingKeeper.GetValidatorDelegations(ctx, oldValAddr) - if err != nil { - return err - } - - // All delegations will reference the same period (currentRewards.Period - 1). - // Reset its reference count to 1 (base) since old delegator references are stale - // after re-keying distribution state. +// +// The delegations, unbonding delegations, and redelegations are supplied by the caller +// (MigrateValidator), which already read them for the pre-migration +// MaxValidatorDelegations count check. Threading them in avoids a second O(N) +// scan of the same staking records on the hot path — for a validator with +// thousands of delegations that redundant read dominated the block time. Passing +// redelegations also avoids a second validator-scoped index scan. Tests may pass +// nil redelegations to exercise the internal scoped scan directly. +func (k Keeper) MigrateValidatorDelegations( + ctx sdk.Context, + oldValAddr, newValAddr sdk.ValAddress, + delegations []stakingtypes.Delegation, + ubds []stakingtypes.UnbondingDelegation, + reds []stakingtypes.Redelegation, +) error { + // All delegations reference the same period (currentRewards.Period - 1). Its + // reference count becomes base(1) + one per re-keyed delegation. Set it in a + // single write here instead of resetting to 1 and incrementing once per + // delegation, which cost N+1 full-chain scans of ValidatorHistoricalRewards. var targetPeriod uint64 if len(delegations) > 0 { currentRewards, err := k.distributionKeeper.GetValidatorCurrentRewards(ctx, newValAddr) @@ -78,7 +87,7 @@ func (k Keeper) MigrateValidatorDelegations(ctx sdk.Context, oldValAddr, newValA return err } targetPeriod = currentRewards.Period - 1 - if err := k.resetHistoricalRewardsReferenceCount(ctx, newValAddr, targetPeriod); err != nil { + if err := k.setHistoricalRewardsReferenceCount(ctx, newValAddr, targetPeriod, uint32(1+len(delegations))); err != nil { return err } } @@ -106,25 +115,19 @@ func (k Keeper) MigrateValidatorDelegations(ctx sdk.Context, oldValAddr, newValA // Initialize fresh distribution starting info for (newValAddr, delegator). // The old starting info was deleted above, so we always construct new info. + // The historical rewards reference count for targetPeriod was already set + // to base(1) + len(delegations) in one write above. startingInfo := distrtypes.DelegatorStartingInfo{ PreviousPeriod: targetPeriod, Height: uint64(ctx.BlockHeight()), Stake: del.Shares, } - if err := k.incrementHistoricalRewardsReferenceCount(ctx, newValAddr, targetPeriod); err != nil { - return err - } if err := k.distributionKeeper.SetDelegatorStartingInfo(ctx, newValAddr, delAddr, startingInfo); err != nil { return err } } - // Re-key unbonding delegations. - ubds, err := k.stakingKeeper.GetUnbondingDelegationsFromValidator(ctx, oldValAddr) - if err != nil { - return err - } - + // Re-key unbonding delegations. (ubds supplied by the caller.) for _, ubd := range ubds { if err := k.stakingKeeper.RemoveUnbondingDelegation(ctx, ubd); err != nil { return err @@ -154,9 +157,12 @@ func (k Keeper) MigrateValidatorDelegations(ctx sdk.Context, oldValAddr, newValA // Re-key redelegations where oldValAddr appears as either source or // destination validator. Existing in-flight redelegations must continue to // point at the migrated validator record after operator migration. - reds, err := k.redelegationsForValidator(ctx, oldValAddr) - if err != nil { - return err + if reds == nil { + var err error + reds, err = k.redelegationsForValidator(ctx, oldValAddr) + if err != nil { + return err + } } for _, red := range reds { diff --git a/x/evmigration/keeper/migrate_validator_scoped.go b/x/evmigration/keeper/migrate_validator_scoped.go index b4ce5afd..b55647ba 100644 --- a/x/evmigration/keeper/migrate_validator_scoped.go +++ b/x/evmigration/keeper/migrate_validator_scoped.go @@ -60,7 +60,7 @@ func (k Keeper) collectRedelegationsByIndex( if err != nil { return err } - defer iterator.Close() + defer func() { _ = iterator.Close() }() for ; iterator.Valid(); iterator.Next() { redKey := indexToRedelegationKey(iterator.Key()) @@ -98,7 +98,7 @@ func (k Keeper) validatorHistoricalRewards(ctx sdk.Context, valAddr sdk.ValAddre if err != nil { return nil, err } - defer iterator.Close() + defer func() { _ = iterator.Close() }() var entries []historicalRewardsEntry for ; iterator.Valid(); iterator.Next() { @@ -110,6 +110,42 @@ func (k Keeper) validatorHistoricalRewards(ctx sdk.Context, valAddr sdk.ValAddre return entries, nil } +// validatorHistoricalReward fetches a single (valAddr, period) historical +// rewards row. With the distribution store wired it is an O(1) key lookup; +// it only falls back to a full IterateValidatorHistoricalRewards scan when the +// store service is unwired (tests that do not wire the scoped stores). +func (k Keeper) validatorHistoricalReward(ctx sdk.Context, valAddr sdk.ValAddress, period uint64) (distrtypes.ValidatorHistoricalRewards, bool, error) { + if k.distributionStoreHandle == nil || k.distributionStoreHandle.svc == nil { + var ( + found bool + historical distrtypes.ValidatorHistoricalRewards + ) + k.distributionKeeper.IterateValidatorHistoricalRewards(ctx, func(val sdk.ValAddress, p uint64, rewards distrtypes.ValidatorHistoricalRewards) bool { + if val.Equals(valAddr) && p == period { + found = true + historical = rewards + return true + } + return false + }) + return historical, found, nil + } + + store := k.distributionStoreHandle.svc.OpenKVStore(ctx) + bz, err := store.Get(distrtypes.GetValidatorHistoricalRewardsKey(valAddr, period)) + if err != nil { + return distrtypes.ValidatorHistoricalRewards{}, false, err + } + if bz == nil { + return distrtypes.ValidatorHistoricalRewards{}, false, nil + } + var historical distrtypes.ValidatorHistoricalRewards + if err := k.cdc.Unmarshal(bz, &historical); err != nil { + return distrtypes.ValidatorHistoricalRewards{}, false, err + } + return historical, true, nil +} + func (k Keeper) validatorSlashEvents(ctx sdk.Context, valAddr sdk.ValAddress) ([]slashEventEntry, error) { if k.distributionStoreHandle == nil || k.distributionStoreHandle.svc == nil { var entries []slashEventEntry @@ -128,7 +164,7 @@ func (k Keeper) validatorSlashEvents(ctx sdk.Context, valAddr sdk.ValAddress) ([ if err != nil { return nil, err } - defer iterator.Close() + defer func() { _ = iterator.Close() }() var entries []slashEventEntry for ; iterator.Valid(); iterator.Next() { diff --git a/x/evmigration/keeper/msg_server_claim_legacy_test.go b/x/evmigration/keeper/msg_server_claim_legacy_test.go index 2830a5a1..750d491f 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -920,7 +920,7 @@ func TestClaimLegacyAccount_FailAtClaim(t *testing.T) { // setupPassingValPreChecks configures mocks so that preChecks, validator-specific // checks, and signature verification pass for MigrateValidator, returning the // addresses, validator addresses, and the ready message. -func setupPassingValPreChecks(t *testing.T, f *msgServerFixture) ( +func setupPassingValPreChecks(t *testing.T, f *msgServerFixture, ubds ...stakingtypes.UnbondingDelegation) ( sdk.AccAddress, sdk.AccAddress, sdk.ValAddress, sdk.ValAddress, *types.MsgMigrateValidator, ) { t.Helper() @@ -943,9 +943,11 @@ func setupPassingValPreChecks(t *testing.T, f *msgServerFixture) ( stakingtypes.Validator{}, stakingtypes.ErrNoValidatorFound, ) - // No delegations/ubds/reds (under limit). + // Pre-check count read: no delegations; ubds default to empty unless a caller + // supplies records to drive a later V4 re-key step. The reds scan hits the + // wired (empty) store. f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(ubds, nil) msg := newValidatorMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) @@ -1001,9 +1003,10 @@ func setupV1toV4(f *mockFixture, oldValAddr, newValAddr sdk.ValAddress) { f.distributionKeeper.EXPECT().IterateValidatorSlashEvents(gomock.Any(), gomock.Any()) f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) - // V4: no delegations. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(nil, nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) + // V4: no delegations. The pre-check already read delegations/ubds and V4 + // reuses those slices instead of re-fetching, so no staking Get mocks here. + // With both slices empty and no redelegations in the wired store, V4 makes + // no staking calls. } // TestMigrateValidator_FailAtValidatorRecord verifies that a failure in @@ -1065,7 +1068,16 @@ func TestMigrateValidator_FailAtValidatorDistribution(t *testing.T) { // MigrateValidatorDelegations (step V4) propagates and no record is stored. func TestMigrateValidator_FailAtValidatorDelegations(t *testing.T) { f := initMsgServerFixture(t) - legacyAddr, _, oldValAddr, newValAddr, msg := setupPassingValPreChecks(t, f) + // The delegation/ubd read moved into the pre-check, so V4 now fails while + // *re-keying* a record, not while fetching one. Feed one unbonding delegation + // through the pre-check count read; V4's RemoveUnbondingDelegation then errors. + // An unbonding delegation avoids the V1 per-delegator reward withdrawal that a + // regular delegation would trigger, keeping this focused on the V4 re-key. + ubd := stakingtypes.UnbondingDelegation{ + DelegatorAddress: testAccAddr().String(), + ValidatorAddress: sdk.ValAddress(testAccAddr()).String(), + } + legacyAddr, _, oldValAddr, newValAddr, msg := setupPassingValPreChecks(t, f, ubd) // Steps V1-V3 succeed. f.distributionKeeper.EXPECT().WithdrawValidatorCommission(gomock.Any(), oldValAddr).Return(sdk.Coins{}, nil) @@ -1094,9 +1106,9 @@ func TestMigrateValidator_FailAtValidatorDelegations(t *testing.T) { f.distributionKeeper.EXPECT().IterateValidatorSlashEvents(gomock.Any(), gomock.Any()) f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) - // Step V4: delegation re-key fails. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return( - nil, fmt.Errorf("delegation index corrupted"), + // Step V4: unbonding-delegation re-key fails on its first store op. + f.stakingKeeper.EXPECT().RemoveUnbondingDelegation(gomock.Any(), ubd).Return( + fmt.Errorf("unbonding index corrupted"), ) _, err := f.msgServer.MigrateValidator(f.ctx, msg) diff --git a/x/evmigration/keeper/msg_server_migrate_validator.go b/x/evmigration/keeper/msg_server_migrate_validator.go index 9e2d69a1..baea150b 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator.go +++ b/x/evmigration/keeper/msg_server_migrate_validator.go @@ -175,7 +175,11 @@ func (ms msgServer) MigrateValidator(goCtx context.Context, msg *types.MsgMigrat } // --- Step V4: Re-key all delegations pointing to this validator --- - if err := ms.MigrateValidatorDelegations(ctx, oldValAddr, newValAddr); err != nil { + // Reuse the delegations/unbondings/redelegations already read for the + // MaxValidatorDelegations pre-check above; nothing since (reward withdrawal, + // record re-key, distribution re-key) mutates these staking records, so a + // second read would be pure overhead on a validator with many delegations. + if err := ms.MigrateValidatorDelegations(ctx, oldValAddr, newValAddr, delegations, ubds, reds); err != nil { return nil, fmt.Errorf("migrate validator delegations: %w", err) } diff --git a/x/evmigration/keeper/msg_server_migrate_validator_test.go b/x/evmigration/keeper/msg_server_migrate_validator_test.go index 28e32a90..921db9f0 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator_test.go +++ b/x/evmigration/keeper/msg_server_migrate_validator_test.go @@ -282,22 +282,18 @@ func TestMigrateValidator_Success(t *testing.T) { f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) // Step V4: MigrateValidatorDelegations — re-key the one delegation. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return( - []stakingtypes.Delegation{del}, nil, - ) - // Reset target period refcount before delegation loop. + // Delegations/unbondings/redelegations are supplied from the pre-check fetch + // above; V4 no longer re-reads them. + // Set target period refcount to base(1) + N delegations in a single write. f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), newValAddr).Return( distrtypes.ValidatorCurrentRewards{Period: 3}, nil, ) - expectHistoricalRewardsReset(f.distributionKeeper, newValAddr, 2, 2) - // Per-delegation re-keying. + expectHistoricalRewardsSet(f.distributionKeeper, newValAddr, 2, 2) + // Per-delegation re-keying (no per-delegation refcount bump — set once above). f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, legacyAddr).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), del).Return(nil) f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) - expectHistoricalRewardsIncrement(f.distributionKeeper, newValAddr, 2, 1) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, legacyAddr, gomock.Any()).Return(nil) - // No unbonding delegations or redelegations. - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) // Step V5: MigrateValidatorSupernode — not a supernode. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) @@ -441,10 +437,11 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) // V4: MigrateValidatorDelegations — re-key self-delegation. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return([]stakingtypes.Delegation{selfDel}, nil) + // Delegations/unbondings are supplied from the pre-check fetch; V4 no longer re-reads. f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), newValAddr).Return(currentRewards, nil) targetPeriod := currentRewards.Period - 1 histRewards := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 1} + // Single write: set target period refcount to base(1) + N delegations. f.distributionKeeper.EXPECT().IterateValidatorHistoricalRewards(gomock.Any(), gomock.Any()).DoAndReturn( func(_ sdk.Context, fn func(sdk.ValAddress, uint64, distrtypes.ValidatorHistoricalRewards) bool) { fn(newValAddr, targetPeriod, histRewards) @@ -454,14 +451,7 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, legacyAddr).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), selfDel).Return(nil) f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) - f.distributionKeeper.EXPECT().IterateValidatorHistoricalRewards(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ sdk.Context, fn func(sdk.ValAddress, uint64, distrtypes.ValidatorHistoricalRewards) bool) { - fn(newValAddr, targetPeriod, histRewards) - }, - ) - f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, legacyAddr, gomock.Any()).Return(nil) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) // V5: no supernode. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) @@ -598,9 +588,9 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { selfDel := stakingtypes.NewDelegation(legacyAddr.String(), oldValAddr.String(), math.LegacyNewDec(100)) thirdDel := stakingtypes.NewDelegation(thirdPartyDelegator.String(), oldValAddr.String(), math.LegacyNewDec(50)) allDels := []stakingtypes.Delegation{selfDel, thirdDel} - // Called twice: once for pre-check count, once inside MigrateValidatorDelegations. - f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(allDels, nil).Times(2) - f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil).Times(2) + // Fetched once for the pre-check count; V4 reuses the same slices instead of re-reading. + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), oldValAddr).Return(allDels, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(nil, nil) // Step V1: Withdraw commission. f.distributionKeeper.EXPECT().WithdrawValidatorCommission(gomock.Any(), oldValAddr).Return(sdk.Coins{}, nil) @@ -643,8 +633,9 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { f.distributionKeeper.EXPECT().IterateValidatorSlashEvents(gomock.Any(), gomock.Any()) f.distributionKeeper.EXPECT().DeleteValidatorSlashEvents(gomock.Any(), oldValAddr) - // Delegation re-keying (2 delegations). - // resetHistoricalRewardsReferenceCount needs the iterate to find the entry. + // Delegation re-keying (2 delegations). The target period refcount is set to + // base(1) + N in a single write before the loop, so the loop does no per- + // delegation refcount bump. The iterate below is the lookup for that one write. targetPeriod := currentRewards.Period - 1 histRewards := distrtypes.ValidatorHistoricalRewards{ReferenceCount: 2} f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), newValAddr).Return(currentRewards, nil) @@ -658,12 +649,6 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, gomock.Any()).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), gomock.Any()).Return(nil) f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) - f.distributionKeeper.EXPECT().IterateValidatorHistoricalRewards(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ sdk.Context, fn func(sdk.ValAddress, uint64, distrtypes.ValidatorHistoricalRewards) bool) { - fn(newValAddr, targetPeriod, histRewards) - }, - ) - f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, gomock.Any(), gomock.Any()).Return(nil) } From fb32f5bb7cf1ad4df356e50a91a48f638853c841 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 08:28:09 -0400 Subject: [PATCH 04/28] evmigration: sort scoped redelegations for deterministic replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wired redelegationsForValidator built its result by ranging a Go map, so the order was process-dependent. MigrateValidatorDelegations replays that slice via InsertRedelegationQueue, which appends to shared queue timeslices — redelegations sharing a completion time could therefore commit in different byte order on different nodes and diverge the app hash (chain halt). Sort the deduped store keys before emitting. This is deterministic and reproduces the store-key order the IterateRedelegations fallback already yields, so wired and unwired paths agree. Addresses the P1 raised in the Codex review of #184. Co-Authored-By: Claude Opus 4.7 --- x/evmigration/keeper/migrate_validator_scoped.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/x/evmigration/keeper/migrate_validator_scoped.go b/x/evmigration/keeper/migrate_validator_scoped.go index b55647ba..b9495b62 100644 --- a/x/evmigration/keeper/migrate_validator_scoped.go +++ b/x/evmigration/keeper/migrate_validator_scoped.go @@ -2,6 +2,7 @@ package keeper import ( "fmt" + "sort" corestore "cosmossdk.io/core/store" storetypes "cosmossdk.io/store/types" @@ -43,9 +44,19 @@ func (k Keeper) redelegationsForValidator(ctx sdk.Context, valAddr sdk.ValAddres return nil, err } + // Emit in sorted store-key order rather than raw map iteration order. The + // slice is replayed by MigrateValidatorDelegations, whose InsertRedelegationQueue + // calls append to shared queue timeslices; a nondeterministic order would + // diverge queue bytes (and the app hash) across nodes. Sorting by the + // redelegation store key also matches the IterateRedelegations fallback above. + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) reds := make([]stakingtypes.Redelegation, 0, len(seen)) - for _, red := range seen { - reds = append(reds, red) + for _, k := range keys { + reds = append(reds, seen[k]) } return reds, nil } From 75a6559da1774b0a41a9482450bfcfdcbb951792 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 08:37:12 -0400 Subject: [PATCH 05/28] evmigration: test deterministic redelegation replay order Locks in the fix for the map-iteration nondeterminism in redelegationsForValidator: the scoped scan now emits redelegations in store-key order, so MigrateValidatorDelegations replays them deterministically across nodes. The test writes 12 redelegations, forces the internal scoped scan (nil reds), and asserts the replay order matches the canonical store-key order. A reverted fix (raw map range) fails this essentially every run (12! possible orderings). Co-Authored-By: Claude Opus 4.7 --- x/evmigration/keeper/migrate_test.go | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index b7f8b530..d3b7e2e0 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -3,6 +3,7 @@ package keeper_test import ( "errors" "fmt" + "sort" "testing" corestore "cosmossdk.io/core/store" @@ -2142,6 +2143,83 @@ func TestMigrateValidatorScopedIteration_SimulatesGlobalStateImprovement(t *test require.Equal(t, newValAddr.String(), rewritten[40_002].ValidatorDstAddress) } +// TestMigrateValidatorDelegations_RedelegationReplayIsDeterministic locks in the +// fix for a consensus-halt bug: redelegationsForValidator collects records into a +// Go map, and iterating that map directly to replay them (RemoveRedelegation -> +// SetRedelegation -> InsertRedelegationQueue) leaked Go's randomized map order into +// state writes. InsertRedelegationQueue appends to shared queue timeslices, so a +// nondeterministic replay order diverges the queue bytes (and the app hash) across +// nodes. The fix emits records in redelegation-store-key order; this test asserts +// the replay order matches that canonical order regardless of insertion order. +// +// With numReds records there are numReds! possible map orderings, so if the sort is +// ever reverted to raw map iteration this test fails essentially every run rather +// than flaking intermittently. +func TestMigrateValidatorDelegations_RedelegationReplayIsDeterministic(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + completionTime := f.ctx.BlockTime().Add(21 * 24 * 3600 * 1e9) + + const numReds = 12 + + type placedRed struct { + key string + unbondingID uint64 + } + placed := make([]placedRed, 0, numReds) + for i := 0; i < numReds; i++ { + del := testAccAddr() + dst := sdk.ValAddress(testAccAddr()) + unbondingID := uint64(70_000 + i) + f.writeRedelegation(stakingtypes.Redelegation{ + DelegatorAddress: del.String(), + ValidatorSrcAddress: oldValAddr.String(), + ValidatorDstAddress: dst.String(), + Entries: []stakingtypes.RedelegationEntry{{ + CreationHeight: int64(i), + CompletionTime: completionTime, + InitialBalance: math.NewInt(100), + SharesDst: math.LegacyNewDec(100), + UnbondingId: unbondingID, + }}, + }) + placed = append(placed, placedRed{ + key: string(stakingtypes.GetREDKey(del, oldValAddr, dst)), + unbondingID: unbondingID, + }) + } + + // Expected replay order == redelegations sorted by their store key, matching + // the canonical order redelegationsForValidator now emits. + sorted := make([]placedRed, len(placed)) + copy(sorted, placed) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].key < sorted[j].key }) + expectedOrder := make([]uint64, len(sorted)) + for i, p := range sorted { + expectedOrder[i] = p.unbondingID + } + + var replayOrder []uint64 + f.stakingKeeper.EXPECT().RemoveRedelegation(gomock.Any(), gomock.Any()).Return(nil).Times(numReds) + f.stakingKeeper.EXPECT().SetRedelegation(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ sdk.Context, red stakingtypes.Redelegation) error { + replayOrder = append(replayOrder, red.Entries[0].UnbondingId) + return nil + }, + ).Times(numReds) + f.stakingKeeper.EXPECT().InsertRedelegationQueue(gomock.Any(), gomock.Any(), completionTime).Return(nil).Times(numReds) + f.stakingKeeper.EXPECT().SetRedelegationByUnbondingID(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).Times(numReds) + + // Passing nil redelegations forces the internal scoped scan (the map path). + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, nil, nil, nil) + require.NoError(t, err) + + require.Equal(t, expectedOrder, replayOrder, "redelegations must replay in deterministic store-key order") +} + // --- Validator-supernode metrics tests --- // TestMigrateValidatorSupernode_WithMetrics verifies that metrics state is From ef81ba0378a2a64b9ba157adf983fd41b7f52eae Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 09:12:03 -0400 Subject: [PATCH 06/28] docs(evm): inventory scoped-migration tests; fix keeper comments Address code-review findings on PR #184: - Add the 12 validator-scoped-iteration tests to unit-evmigration.md and bump the EVMigration keeper count 124+ -> 136+ in tests.md, per the CLAUDE.md rule that new EVM tests be tracked in the test inventory. - Correct the stakingStoreHandle comments in keeper.go: it is no longer used exclusively by DeleteValidatorRecordNoHooks. redelegationsForValidator now reads it for validator-scoped redelegation index scans (read/write). Co-Authored-By: Claude Opus 4.7 --- docs/evm-integration/testing/tests.md | 4 ++-- .../testing/tests/unit-evmigration.md | 12 ++++++++++++ x/evmigration/keeper/keeper.go | 10 ++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/evm-integration/testing/tests.md b/docs/evm-integration/testing/tests.md index 873c7c09..2b64e0ed 100644 --- a/docs/evm-integration/testing/tests.md +++ b/docs/evm-integration/testing/tests.md @@ -51,7 +51,7 @@ All three previously identified critical test gaps (mempool capacity pressure, b | **Unit** | OpenRPC / generator | 15 | High — [details](tests/unit-openrpc.md) | | **Unit** | JSON-RPC rate limiting | 25 | High — right-to-left XFF parsing, trusted-hop skipping, CIDR parsing | | **Unit** | ERC20 policy | 14 | High — 3 modes, base denom + exact ibc/ allowlist CRUD | -| **Unit** | EVMigration keeper | 124+ | Excellent — [details](tests/unit-evmigration.md) | +| **Unit** | EVMigration keeper | 136+ | Excellent — [details](tests/unit-evmigration.md) | | **Unit** | EVMigration types (proof) | 6 | High — `TestMultisigProof_ValidateBasic`, `TestMultisigProof_ValidateParams_SizeCap`, `TestLegacyProof_ValidateBasic_Dispatch`, `TestSingleKeyProof_ValidateBasic` and variants | | **Unit** | EVMigration CLI | 26 | High — [details](tests/unit-evmigration-cli.md) | | **Unit** | Cross-runtime bridge (plugin helpers + crossruntime) | 46 | High — [details](tests/integration-precompiles.md#cosmwasm---evm-plugin-unit-tests) | @@ -115,7 +115,7 @@ Each area has its own detailed file with per-test descriptions: | Fee market (EIP-1559) | [unit-feemarket.md](tests/unit-feemarket.md) | 9 | | Precisebank (6↔18 bridge) | [unit-precisebank.md](tests/unit-precisebank.md) | 39 | | OpenRPC & generator | [unit-openrpc.md](tests/unit-openrpc.md) | 15 | -| EVMigration keeper | [unit-evmigration.md](tests/unit-evmigration.md) | 124+ | +| EVMigration keeper | [unit-evmigration.md](tests/unit-evmigration.md) | 136+ | | EVMigration types (proof) | `x/evmigration/types/proof_test.go` | 6 | | EVMigration CLI | [unit-evmigration-cli.md](tests/unit-evmigration-cli.md) | 26 | diff --git a/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index 42665232..21c952a8 100644 --- a/docs/evm-integration/testing/tests/unit-evmigration.md +++ b/docs/evm-integration/testing/tests/unit-evmigration.md @@ -114,6 +114,18 @@ Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/ | `TestQueryParams_Valid` | Valid request returns stored params. | | `TestUpdateParams_InvalidAuthority` | Non-authority address rejected with ErrInvalidSigner. | | `TestUpdateParams_ValidAuthority` | Correct authority updates params successfully. | +| `TestMigrateValidatorDelegations_UsesScopedRedelegationIndexes` | V4's internal scoped scan discovers redelegations where the validator is source or destination (via the val-src/val-dst indexes) and re-keys exactly those, skipping unrelated ones. | +| `TestMigrateValidatorDelegations_DeduplicatesSourceAndDestinationIndexes` | A redelegation with the migrating validator as both source and destination appears in both indexes but is collected and re-keyed exactly once. | +| `TestMigrateValidatorDelegations_UsesPreloadedRedelegations` | A non-nil caller-supplied redelegation slice is re-keyed directly with no scoped-index rescan (store intentionally holds no redelegation rows). | +| `TestMigrateValidatorDelegations_ReturnsErrorForStaleRedelegationIndex` | A redelegation index entry with no backing record aborts V4 with a "points to missing record" error before re-keying anything. | +| `TestMigrateValidatorDistribution_UsesScopedDistributionPrefixes` | Distribution migration reads only the migrating validator's historical-rewards/slash-event prefixes, leaving an unrelated validator's rows untouched. | +| `TestMigrateValidatorDelegations_RekeysMultipleSourceRedelegations` | Two redelegations sharing the val-src index prefix are both collected (iterator advances past the first key) and re-keyed to the new operator. | +| `TestMigrateValidatorDelegations_SetsHistoricalRewardsRefCountOnce` | The target period's historical-rewards reference count is written exactly once as base(1)+N via the scoped O(1) lookup, not reset-then-incremented per delegation. | +| `TestMigrateValidatorDistribution_RekeysAllPeriodsAndSlashEvents` | All of the validator's historical-rewards periods and slash events are re-keyed to the new address; height (from key) and period (from value) are not swapped. | +| `TestMigrateValidatorScopedIteration_SimulatesGlobalStateImprovement` | Simulates a large-chain state shape and asserts validator-scoped iteration touches ~215x fewer KV keys than the old full-chain scan. | +| `TestMigrateValidatorDelegations_RedelegationReplayIsDeterministic` | Redelegations from the scoped scan replay in deterministic store-key order, guarding against Go map-iteration nondeterminism that would diverge app hashes across nodes. | +| `TestMigrateValidator_TooManyDelegatorsIncludesScopedRedelegations` | The MaxValidatorDelegations pre-check counts scoped redelegations (source and destination); exceeding the limit rejects with `ErrTooManyDelegators` even with no plain delegations/unbondings. | +| `TestQueryMigrationEstimate_ValidatorUsesScopedRedelegationIndexesForLimit` | The `MigrationEstimate` query counts a validator's redelegations via scoped indexes (source and destination, excluding unrelated) when reporting the delegation count. | **Additional regression coverage**: `TestKeeper_GetSuperNodeByAccount` (in `x/supernode/v1/keeper/`) confirms `GetSuperNodeByAccount` returns the correct supernode for a given account address, exercising the index used by `MigrateSupernode`. diff --git a/x/evmigration/keeper/keeper.go b/x/evmigration/keeper/keeper.go index 3be79061..5040e369 100644 --- a/x/evmigration/keeper/keeper.go +++ b/x/evmigration/keeper/keeper.go @@ -15,7 +15,8 @@ import ( // Populated post-depinject via SetStakingStoreService. The pointer is shared // across all Keeper copies (value-type Keeper returned by NewKeeper gets // cloned to app.EvmigrationKeeper and AppModule.keeper; both copies see the -// same underlying cell). Only used by DeleteValidatorRecordNoHooks. +// same underlying cell). Used by DeleteValidatorRecordNoHooks (raw writes) +// and redelegationsForValidator (validator-scoped redelegation index reads). type stakingStoreHandle struct { svc corestore.KVStoreService } @@ -36,9 +37,10 @@ type Keeper struct { Schema collections.Schema Params collections.Item[types.Params] - // stakingStoreHandle grants this keeper migration-only raw write access to - // x/staking's KV namespace. Wired post-build in app.go via - // SetStakingStoreService; used exclusively by DeleteValidatorRecordNoHooks. + // stakingStoreHandle grants this keeper migration-only raw read/write access + // to x/staking's KV namespace. Wired post-build in app.go via + // SetStakingStoreService; used by DeleteValidatorRecordNoHooks (writes) and + // redelegationsForValidator (validator-scoped redelegation index reads). stakingStoreHandle *stakingStoreHandle // distributionStoreHandle grants this keeper migration-only raw read access // to x/distribution's KV namespace for validator-scoped iteration. From 4a555b29dae805d4a901404209dd5731c937437c Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 09:24:42 -0400 Subject: [PATCH 07/28] docs: clarify migration wallet usage --- .../evmigration/legacy-migration.md | 2 +- docs/evm-integration/user-guides/main.md | 1 + docs/evm-integration/user-guides/migration.md | 28 ++++++++++++++++++- .../evm-integration/user-guides/tune-guide.md | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/evm-integration/evmigration/legacy-migration.md b/docs/evm-integration/evmigration/legacy-migration.md index c48302a6..f1fc543d 100644 --- a/docs/evm-integration/evmigration/legacy-migration.md +++ b/docs/evm-integration/evmigration/legacy-migration.md @@ -45,7 +45,7 @@ x/evmigration/ | `enable_migration` | `true` | Master switch | | `migration_end_time` | `0` | Unix timestamp deadline | | `max_migrations_per_block` | `50` | Rate limit | -| `max_validator_delegations` | `2000` | Max delegators for validator migration | +| `max_validator_delegations` | `2500` | Max delegators for validator migration | ### Fee waiving diff --git a/docs/evm-integration/user-guides/main.md b/docs/evm-integration/user-guides/main.md index 4265a57c..ac6059ca 100644 --- a/docs/evm-integration/user-guides/main.md +++ b/docs/evm-integration/user-guides/main.md @@ -57,3 +57,4 @@ Mainnet-readiness review of every parameter that affects fees, throughput, UX, o - **Validators must use `MsgMigrateValidator`, not `MsgClaimLegacyAccount`.** The chain explicitly rejects `claim-legacy-account` for validator operator addresses. `MsgMigrateValidator` is a superset that re-keys delegations, distribution state, and any bound supernode atomically. - **Multisig migrations always mirror.** A K-of-N legacy multisig migrates to a K-of-N `eth_secp256k1` multisig — same K, same N. This is a consensus invariant, enforced at `ValidateBasic` (`ErrMirrorSourceMismatch`, code 1121). The destination can do all Cosmos-side ops (staking, IBC, supernode, authz) but cannot originate `MsgEthereumTx`; configure a separate single-EOA `MsgSetWithdrawAddress` if you need EVM DeFi access. - **Migration is irreversible.** The legacy account is removed from the auth module post-migration; balances become 0 at the legacy address; the migration record (legacy → new mapping) is permanent. +- **After migrating, both Keplr and MetaMask work for the same account.** The migrated `eth_secp256k1` key has two representations — a Cosmos bech32 (`lumera1...`, shown by Keplr) and an Ethereum hex (`0x...`, shown by MetaMask) — that point to the same funds and state through different endpoints (Cosmos LCD vs. EVM JSON-RPC). Use MetaMask for EVM/DeFi, Keplr for Cosmos-native ops. Keplr requires switching to the EVM chain profile (coin-type 60) and re-importing first. See [migration.md § Using both Keplr and MetaMask after migration](migration.md#using-both-keplr-and-metamask-after-migration). diff --git a/docs/evm-integration/user-guides/migration.md b/docs/evm-integration/user-guides/migration.md index 7e240a42..eaf0707c 100644 --- a/docs/evm-integration/user-guides/migration.md +++ b/docs/evm-integration/user-guides/migration.md @@ -52,6 +52,33 @@ After migration: - Both addresses must come from the**same mnemonic** (same seed phrase) - The migration transaction is unsigned at the Cosmos tx layer; authentication is embedded in the message as dual cryptographic proofs +### Using both Keplr and MetaMask after migration + +After the upgrade **and** account migration, you can use **both Keplr and MetaMask for the same account**. + +Your migrated account has two address representations: + +- **Cosmos bech32** — `lumera1xxxxx...` +- **Ethereum hex** — `0x...` + +These are **not two different accounts** — they are two ways of writing the **same** account, pointing to the same funds, the same staking, the same everything. Keplr shows the `lumera1...` spelling; MetaMask shows the `0x...` spelling. The Portal displays both side-by-side so you can see they're the same thing. + +The two wallets just reach that account through **different Lumera endpoints (URLs)**: + +- **Keplr** uses Cosmos endpoints (testnet: `https://lcd.testnet.lumera.io`) +- **MetaMask** uses Ethereum JSON-RPC endpoints (testnet: `https://evm-rpc.testnet.lumera.io`) + +It's basically about which door each wallet knocks on — both doors lead to the same room. Using a Cosmos URL in MetaMask (or an EVM URL in Keplr) will not work. + +- Use **MetaMask** for EVM/DeFi activity, Ethereum dApps, and anything that works with your `0x...` address. See [metamask-configuration.md](metamask-configuration.md). +- Use **Keplr** for Cosmos-native activity — staking, governance, IBC transfers. + +**Important:** + +- You must first **migrate** your legacy account (coin-type 118, `secp256k1`) to the new EVM account (coin-type 60, `eth_secp256k1`). Before migration, MetaMask cannot use a legacy account at all — there is no `0x...` address to connect to. +- **Keplr users must switch to the Lumera EVM chain profile** (coin-type 60) after migrating, and re-import their account, before Keplr will show the migrated key. An existing legacy profile won't show it on its own — see [§ State A: Wallet Re-Import Still Required](#state-a-wallet-re-import-still-required-still-on-the-legacy-profile). +- A migrated **multisig** account is Cosmos-only (it has no usable `0x...` form), so it cannot be used in MetaMask; single-key accounts have no such limitation. + --- ## Validator migration @@ -329,7 +356,6 @@ After the chain config is on `60` but before you re-import the mnemonic, the sam ![State panel — Portal and chain on coin-type 60, but Keplr account key still legacy 118](../assets/evmigration-18.png) -> **Why a fresh profile rather than just using the existing one?** A Keplr wallet profile derives its keys from the mnemonic at *creation time* using the chain's then-current `bip44.coinType`. Existing profiles aren't re-derived when the chain config later changes. Importing the same mnemonic into a new profile, after the chain registry is on `coin-type 60`, makes Keplr derive the EVM-compatible (P_60) key. ###### e. Re-import the mnemonic into a fresh Keplr profile diff --git a/docs/evm-integration/user-guides/tune-guide.md b/docs/evm-integration/user-guides/tune-guide.md index 7b827770..1fb0320f 100644 --- a/docs/evm-integration/user-guides/tune-guide.md +++ b/docs/evm-integration/user-guides/tune-guide.md @@ -475,7 +475,7 @@ These parameters affect **RPC node operators** and **dApp developers**, not end- | `enable_migration` | `true` | Yes — disable after migration window closes | | `migration_end_time` | `0` (no deadline) | **Yes — set a deadline for mainnet** | | `max_migrations_per_block` | `50` | Review based on expected migration volume | -| `max_validator_delegations` | `2,000` | Review based on largest validator delegation count | +| `max_validator_delegations` | `2,500` | Review based on largest validator delegation count | **Recommendation:** **Set `migration_end_time` to a specific date before mainnet.** Open-ended migration windows are a governance and security risk. Consider 30-90 days post-launch. From ebb8b11ad5166bcbcf010f0cb24e1f76e28d0938 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 09:26:17 -0400 Subject: [PATCH 08/28] test: cover chain-helper network defaults --- tests/scripts/chain-helper.bats | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index a9163c0e..8de75420 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -339,6 +339,51 @@ teardown() { [[ "$output" == *"unknown network"* ]] } +@test "max-validator-delegations defaults to testnet when no network is selected" { + # LUMERA_* env vars would otherwise seed CHAIN_ID/NODE/GRPC before parsing and + # mask the built-in default, so scrub them to assert on the shipped default. + run env -u LUMERA_CHAIN_ID -u LUMERA_NODE -u LUMERA_RPC -u LUMERA_GRPC \ + "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "lumera-testnet-2" + and .rpc == "https://rpc.testnet.lumera.io:443" + and .grpc == "grpc.testnet.lumera.io:443" + ' +} + +@test "network defaults fill only the endpoints not passed explicitly" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network mainnet \ + --node tcp://custom-rpc:26657 \ + --json + + [ "$status" -eq 0 ] + # Only --node was given: chain-id and grpc must still come from the mainnet + # defaults, proving the per-field flag-set checks are independent. + echo "$output" | jq -e ' + .chain_id == "lumera-mainnet-1" + and .rpc == "tcp://custom-rpc:26657" + and .grpc == "grpc.lumera.io:443" + ' +} + +@test "max-validator-delegations rejects --network without a value" { + run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network + + [ "$status" -eq 1 ] + [[ "$output" == *"--network requires a value"* ]] +} + @test "max-validator-delegations uses staking and grpcurl on pre-evm chain" { run env FAKE_EXACT_UNAVAILABLE=1 \ "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ From dd9773c9e409beca0f668e41668084e287369b82 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 11:31:22 -0400 Subject: [PATCH 09/28] fix(devnet): move lumera uploader grpc default port --- devnet/config/config-no-hermes.json | 2 +- devnet/config/config.json | 2 +- devnet/config/validators.json | 2 +- devnet/dockerfile | 2 +- devnet/generators/config.go | 2 +- devnet/generators/docker-compose_test.go | 34 ++++++++++++++++++++++++ devnet/scripts/lumera-uploader-setup.sh | 6 ++--- docs/devnet/configuration.md | 10 +++---- docs/devnet/lumera-uploader.md | 12 ++++----- docs/devnet/main.md | 2 +- 10 files changed, 54 insertions(+), 20 deletions(-) diff --git a/devnet/config/config-no-hermes.json b/devnet/config/config-no-hermes.json index df89ff1f..8df834ba 100644 --- a/devnet/config/config-no-hermes.json +++ b/devnet/config/config-no-hermes.json @@ -27,7 +27,7 @@ }, "network-maker": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080, "max_accounts": 3, "account_balance": "10000000ulume" diff --git a/devnet/config/config.json b/devnet/config/config.json index d8a13a28..ee4873de 100644 --- a/devnet/config/config.json +++ b/devnet/config/config.json @@ -68,7 +68,7 @@ }, "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080, "max_accounts": 3, "account_balance": "10000000ulume" diff --git a/devnet/config/validators.json b/devnet/config/validators.json index bcce90be..1a0731ca 100644 --- a/devnet/config/validators.json +++ b/devnet/config/validators.json @@ -80,7 +80,7 @@ }, "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080 }, "initial_distribution": { diff --git a/devnet/dockerfile b/devnet/dockerfile index 2c808a2c..8511296d 100644 --- a/devnet/dockerfile +++ b/devnet/dockerfile @@ -73,7 +73,7 @@ ENV PATH="${SCRIPTS_DEST_DIR}/migration:${PATH}" ENV CHAIN_ID=lumera-devnet-1 # Expose necessary ports -EXPOSE 26656 26657 1317 9090 4444 8002 50051 8080 8088 +EXPOSE 26656 26657 1317 9090 4444 8002 15051 8080 8088 # Set working directory WORKDIR /root diff --git a/devnet/generators/config.go b/devnet/generators/config.go index 9984804e..612aa7c5 100644 --- a/devnet/generators/config.go +++ b/devnet/generators/config.go @@ -9,7 +9,7 @@ const ( DefaultSupernodePort = 4444 DefaultSupernodeP2PPort = 4445 DefaultSupernodeGatewayPort = 8002 - DefaultLumeraUploaderGRPCPort = 50051 + DefaultLumeraUploaderGRPCPort = 15051 DefaultLumeraUploaderHTTPPort = 8080 DefaultLumeraUploaderUIPort = 8088 DefaultGRPCWebPort = 9091 diff --git a/devnet/generators/docker-compose_test.go b/devnet/generators/docker-compose_test.go index 61a34585..e8466e21 100644 --- a/devnet/generators/docker-compose_test.go +++ b/devnet/generators/docker-compose_test.go @@ -124,3 +124,37 @@ func TestGenerateDockerComposeDoesNotDisableValidatorOneHostReporterByDefault(t t.Fatalf("EVERLIGHT_TEST_TARGET = %q, want %q", got, "0") } } + +func TestGenerateDockerComposeUsesLumeraUploaderDefaultGRPCPort(t *testing.T) { + cfg := newEVMChainConfig("v1.12.0") + validators := []confg.Validator{ + { + Name: "supernova_validator_3", + Moniker: "validator-3", + Port: 26686, + RPCPort: 26687, + RESTPort: 1347, + GRPCPort: 9093, + LumeraUploader: struct { + Enabled bool `json:"enabled,omitempty"` + GRPCPort int `json:"grpc_port,omitempty"` + HTTPPort int `json:"http_port,omitempty"` + }{ + Enabled: true, + }, + }, + } + + compose, err := GenerateDockerCompose(cfg, validators, false) + if err != nil { + t.Fatalf("GenerateDockerCompose() error = %v", err) + } + + ports := compose.Services["supernova_validator_3"].Ports + if !hasPort(ports, "15051:15051") { + t.Fatalf("lumera-uploader default gRPC mapping missing; got ports %v", ports) + } + if containsContainerPort(ports, "50051") { + t.Fatalf("lumera-uploader should not publish reserved container port 50051 by default; got ports %v", ports) + } +} diff --git a/devnet/scripts/lumera-uploader-setup.sh b/devnet/scripts/lumera-uploader-setup.sh index 61ac6ce7..8c10748f 100755 --- a/devnet/scripts/lumera-uploader-setup.sh +++ b/devnet/scripts/lumera-uploader-setup.sh @@ -28,7 +28,7 @@ # Environment: # MONIKER - Validator moniker, set by docker-compose # START_MODE - "run" (default) or "wait" -# NM_GRPC_PORT - gRPC listen port (default 50051) +# NM_GRPC_PORT - gRPC listen port (default 15051) # NM_HTTP_PORT - HTTP gateway port (default 8080) # set -euo pipefail @@ -92,7 +92,7 @@ NM_FILES_DIR_SHARED="/shared/nm-files" # Shared scanner directory (across conta NM_LOG="${NM_LOG:-/root/logs/${NM}.log}" NM_TEMPLATE="${RELEASE_DIR}/uploader-config.toml" # Config template from host NM_CONFIG="${NM_HOME}/config.toml" # Active config (patched from template) -NM_GRPC_PORT="${NM_GRPC_PORT:-50051}" +NM_GRPC_PORT="${NM_GRPC_PORT:-15051}" NM_HTTP_PORT="${NM_HTTP_PORT:-8080}" echo "[UL] Using uploader binary name: ${NM}" @@ -194,7 +194,7 @@ VAL_REC_JSON="$(jq -c --arg m "$MONIKER" '[.[] | select(.moniker==$m)][0]' "${CF NM_ENABLED="$(echo "${VAL_REC_JSON}" | jq -r 'try .["lumera-uploader"].enabled // .["lumera-uploader"] // "false"')" NM_GRPC_PORT="$(echo "${VAL_REC_JSON}" | jq -r 'try .["lumera-uploader"].grpc_port // empty')" NM_HTTP_PORT="$(echo "${VAL_REC_JSON}" | jq -r 'try .["lumera-uploader"].http_port // empty')" -if [ -z "${NM_GRPC_PORT}" ] || [ "${NM_GRPC_PORT}" = "null" ]; then NM_GRPC_PORT="${NM_GRPC_PORT:-50051}"; fi +if [ -z "${NM_GRPC_PORT}" ] || [ "${NM_GRPC_PORT}" = "null" ]; then NM_GRPC_PORT="${NM_GRPC_PORT:-15051}"; fi if [ -z "${NM_HTTP_PORT}" ] || [ "${NM_HTTP_PORT}" = "null" ]; then NM_HTTP_PORT="${NM_HTTP_PORT:-8080}"; fi # ─── Short-Circuit Checks ───────────────────────────────────────────────────── diff --git a/docs/devnet/configuration.md b/docs/devnet/configuration.md index e877821b..67060b55 100644 --- a/docs/devnet/configuration.md +++ b/docs/devnet/configuration.md @@ -51,7 +51,7 @@ Global chain parameters shared by every validator. Loaded by `devnet/config/conf }, "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080, "max_accounts": 3, "account_balance": "10000000ulume" @@ -126,7 +126,7 @@ Array of BIP-39 mnemonics for Supernode and sncli accounts. The first N entries | Field | Type | Description | | --- | --- | --- | | `enabled` | bool | Global enable flag | -| `grpc_port` | int | gRPC listen port (default 50051) | +| `grpc_port` | int | gRPC listen port (default 15051) | | `http_port` | int | HTTP gateway listen port (default 8080) | | `max_accounts` | int | Number of funded uploader accounts to create per validator (minimum 1) | | `account_balance` | string | Funding amount per account (with or without denom suffix) | @@ -168,7 +168,7 @@ Array of validator specifications. Each entry defines one validator container wi }, "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080 }, "multisig": { @@ -278,7 +278,7 @@ The devnet runs five validators plus a Hermes IBC relayer on a private Docker br | Supernode gRPC | `4444` | Action processing service | | Supernode P2P | `4445` | Supernode-to-supernode gossip | | Supernode HTTP gateway | `8002` | Supernode REST gateway | -| Lumera-uploader gRPC | `50051` | Only when `lumera-uploader.enabled = true` | +| Lumera-uploader gRPC | `15051` | Only when `lumera-uploader.enabled = true` | | Lumera-uploader HTTP | `8080` | Only when `lumera-uploader.enabled = true` | | Delve debugger | `40000` | Only when the binary is built in debug mode | @@ -288,7 +288,7 @@ The devnet runs five validators plus a Hermes IBC relayer on a private Docker br | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 1 | `supernova_validator_1` | `172.28.0.11` | 26666 | 26667 | 1327 | 9091 | 8545 | 8546 | 7441 | 7442 | 18001 | 40000 | — | | 2 | `supernova_validator_2` | `172.28.0.12` | 26676 | 26677 | 1337 | 9092 | 8555 | 8556 | 7443 | 7444 | 18002 | 40001 | — | -| 3 | `supernova_validator_3` | `172.28.0.13` | 26686 | 26687 | 1347 | 9093 | 8565 | 8566 | 7445 | 7446 | 18003 | 40002 | 50051 / 8080 | +| 3 | `supernova_validator_3` | `172.28.0.13` | 26686 | 26687 | 1347 | 9093 | 8565 | 8566 | 7445 | 7446 | 18003 | 40002 | 15051 / 8080 | | 4 | `supernova_validator_4` | `172.28.0.14` | 26696 | 26697 | 1357 | 9094 | 8575 | 8576 | 7447 | 7448 | 18004 | 40003 | — | | 5 | `supernova_validator_5` | `172.28.0.15` | 26606 | 26607 | 1367 | 9095 | 8585 | 8586 | 7449 | 7450 | 18005 | 40004 | — | | — | `hermes` | `172.28.0.10` | 36656 | 36657 | 31317 | 39090 / 39091 | — | — | — | — | — | — | — | diff --git a/docs/devnet/lumera-uploader.md b/docs/devnet/lumera-uploader.md index 2dc010d0..09b75015 100644 --- a/docs/devnet/lumera-uploader.md +++ b/docs/devnet/lumera-uploader.md @@ -20,7 +20,7 @@ The binary name is resolved at runtime by `resolve_uploader_name()` in `common.s ```json "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080, "max_accounts": 3, "account_balance": "10000000ulume" @@ -30,7 +30,7 @@ The binary name is resolved at runtime by `resolve_uploader_name()` in `common.s | Field | Type | Default | Description | | ------------------- | ------ | ----------------- | ------------------------------------------------- | | `enabled` | bool | `true` | Global enable flag | -| `grpc_port` | int | `50051` | gRPC listen port | +| `grpc_port` | int | `15051` | gRPC listen port | | `http_port` | int | `8080` | HTTP gateway listen port | | `max_accounts` | int | `1` | Number of funded accounts to create per validator | | `account_balance` | string | `10000000ulume` | Funding amount per account | @@ -45,7 +45,7 @@ Enable the uploader on specific validators: "moniker": "supernova_validator_3", "lumera-uploader": { "enabled": true, - "grpc_port": 50051, + "grpc_port": 15051, "http_port": 8080 } } @@ -107,7 +107,7 @@ chain_id = "lumera-devnet-1" denom = "ulume" [lumera-uploader] # section name matches the resolved binary name -grpc_listen = "0.0.0.0:50051" +grpc_listen = "0.0.0.0:15051" http_gateway_listen = "0.0.0.0:8080" [keyring] @@ -135,7 +135,7 @@ When the chain upgrades to v1.20.0+, uploader account keys are migrated from `se | Service | Default port | Description | | ------------ | ------------ | ----------------------------- | -| gRPC | 50051 | Uploader gRPC API | +| gRPC | 15051 | Uploader gRPC API | | HTTP gateway | 8080 | Uploader HTTP gateway | | UI (nginx) | 8088 | Static web UI served by nginx | @@ -149,7 +149,7 @@ If the `uploader-ui/` directory is present in the release, nginx serves the stat | --------------------------------- | -------------------------------- | ---------------------------------------------------------------------- | | `MONIKER` | (required) | Validator moniker, set by docker-compose | | `START_MODE` | `run` | `run` = full setup + start; `wait` = wait for chain readiness only | -| `NM_GRPC_PORT` | `50051` | Override gRPC port | +| `NM_GRPC_PORT` | `15051` | Override gRPC port | | `NM_HTTP_PORT` | `8080` | Override HTTP gateway port | | `NM_LOG` | `/root/logs/.log` | Log file path | | `NM_UI_PORT` | `8088` | Nginx UI port | diff --git a/docs/devnet/main.md b/docs/devnet/main.md index 9003aa89..89c8218f 100644 --- a/docs/devnet/main.md +++ b/docs/devnet/main.md @@ -222,4 +222,4 @@ Go structs that deserialize `config.json` and `validators.json`. Used by the gen Based on `debian:trixie-slim`. Installs system tools (`jq`, `crudini`, `nginx-light`, `ripgrep`, `Node.js`) and copies all setup scripts. Entrypoint: `/root/scripts/start.sh`. -Exposed ports: `26656 26657 1317 9090 4444 8002 50051 8080 8088`. +Exposed ports: `26656 26657 1317 9090 4444 8002 15051 8080 8088`. From 384d1529944ae2c415ef8aeb74162416fd59091d Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 11:37:54 -0400 Subject: [PATCH 10/28] feat(upgrades): add v1.20.1 handler --- app/upgrades/upgrades.go | 7 +++++++ app/upgrades/upgrades_test.go | 15 +++++++++++++++ app/upgrades/v1_20_1/upgrade.go | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 app/upgrades/v1_20_1/upgrade.go diff --git a/app/upgrades/upgrades.go b/app/upgrades/upgrades.go index 10f8371f..af62211b 100644 --- a/app/upgrades/upgrades.go +++ b/app/upgrades/upgrades.go @@ -17,6 +17,7 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -41,6 +42,7 @@ import ( // | v1.11.1 | custom | conditional add audit store | Supports direct v1.10.1->v1.11.1 and enforces audit min_disk_free_percent floor (>=15) // | v1.12.0 | custom | none (Everlight in supernode) | Runs migrations; Everlight logic embedded in x/supernode // | v1.20.0 | custom | add feemarket, precisebank, vm, erc20 | Adds EVM stores and applies Lumera EVM param finalization +// | v1.20.1 | custom | none | Hotfix release; migrations only // ================================================================================================================================= type UpgradeConfig struct { @@ -72,6 +74,7 @@ var upgradeNames = []string{ upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, + upgrade_v1_20_1.UpgradeName, } var NoUpgradeConfig = UpgradeConfig{ @@ -156,6 +159,10 @@ func SetupUpgrades(upgradeName string, params appParams.AppUpgradeParams) (Upgra StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), }, true + case upgrade_v1_20_1.UpgradeName: + return UpgradeConfig{ + Handler: upgrade_v1_20_1.CreateUpgradeHandler(params), + }, true // add future upgrades here default: diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index ac1fa6d3..7e45559f 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -17,6 +17,7 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -45,6 +46,7 @@ func TestUpgradeNamesOrder(t *testing.T) { upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, + upgrade_v1_20_1.UpgradeName, } require.Equal(t, expected, upgradeNames, "upgradeNames should stay in ascending order") } @@ -136,6 +138,19 @@ func TestV1200SkipsEVMInitGenesis(t *testing.T) { "upstream DefaultParams().EvmDenom should be the extended EVM denom — if this changes, review the fromVM skip in v1.20.0") } +func TestV1201IsRegisteredAsMigrationOnlyUpgrade(t *testing.T) { + params := newTestUpgradeParams("lumera-testnet-2") + config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) + require.True(t, found) + require.NotNil(t, config.Handler) + require.Nil(t, config.StoreUpgrade, "v1.20.1 should not declare store changes") + + ctx := sdk.NewContext(nil, tmproto.Header{ChainID: "lumera-testnet-2"}, false, params.Logger) + vm, err := config.Handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + require.NoError(t, err) + require.NotNil(t, vm) +} + func newTestUpgradeParams(chainID string) appParams.AppUpgradeParams { return appParams.AppUpgradeParams{ ChainID: chainID, diff --git a/app/upgrades/v1_20_1/upgrade.go b/app/upgrades/v1_20_1/upgrade.go new file mode 100644 index 00000000..5cbfec4f --- /dev/null +++ b/app/upgrades/v1_20_1/upgrade.go @@ -0,0 +1,34 @@ +package v1_20_1 + +import ( + "context" + "fmt" + + upgradetypes "cosmossdk.io/x/upgrade/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + + appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" +) + +// UpgradeName is the on-chain name used for this upgrade. +const UpgradeName = "v1.20.1" + +// CreateUpgradeHandler runs module migrations for the v1.20.1 hotfix release. +func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHandler { + return func(goCtx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { + p.Logger.Info(fmt.Sprintf("Starting upgrade %s...", UpgradeName)) + ctx := sdk.UnwrapSDKContext(goCtx) + + p.Logger.Info("Running module migrations...") + newVM, err := p.ModuleManager.RunMigrations(ctx, p.Configurator, fromVM) + if err != nil { + p.Logger.Error("Failed to run migrations", "error", err) + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + p.Logger.Info("Module migrations completed.") + + p.Logger.Info(fmt.Sprintf("Successfully completed upgrade %s", UpgradeName)) + return newVM, nil + } +} From 7ef11c8c7cee139a7a03f21bb73f0126377fd80b Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 12:19:03 -0400 Subject: [PATCH 11/28] refactor(upgrades): use standard handler for v1.20.1 --- app/upgrades/upgrades.go | 18 ++++++++--------- app/upgrades/upgrades_test.go | 5 ++--- app/upgrades/v1_20_1/upgrade.go | 34 --------------------------------- 3 files changed, 11 insertions(+), 46 deletions(-) delete mode 100644 app/upgrades/v1_20_1/upgrade.go diff --git a/app/upgrades/upgrades.go b/app/upgrades/upgrades.go index af62211b..e295c019 100644 --- a/app/upgrades/upgrades.go +++ b/app/upgrades/upgrades.go @@ -17,7 +17,6 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" - upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -42,7 +41,7 @@ import ( // | v1.11.1 | custom | conditional add audit store | Supports direct v1.10.1->v1.11.1 and enforces audit min_disk_free_percent floor (>=15) // | v1.12.0 | custom | none (Everlight in supernode) | Runs migrations; Everlight logic embedded in x/supernode // | v1.20.0 | custom | add feemarket, precisebank, vm, erc20 | Adds EVM stores and applies Lumera EVM param finalization -// | v1.20.1 | custom | none | Hotfix release; migrations only +// | v1.20.1 | standard | none | Hotfix release; migrations only // ================================================================================================================================= type UpgradeConfig struct { @@ -52,10 +51,11 @@ type UpgradeConfig struct { // Migration-only upgrades that use the standard handler. const ( - upgradeNameV170 = "v1.7.0" - upgradeNameV172 = "v1.7.2" - upgradeNameV185 = "v1.8.5" - upgradeNameV191 = "v1.9.1" + upgradeNameV170 = "v1.7.0" + upgradeNameV172 = "v1.7.2" + upgradeNameV185 = "v1.8.5" + upgradeNameV191 = "v1.9.1" + upgradeNameV1201 = "v1.20.1" ) // List of all known upgrade names, in chronological order. @@ -74,7 +74,7 @@ var upgradeNames = []string{ upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, - upgrade_v1_20_1.UpgradeName, + upgradeNameV1201, } var NoUpgradeConfig = UpgradeConfig{ @@ -159,9 +159,9 @@ func SetupUpgrades(upgradeName string, params appParams.AppUpgradeParams) (Upgra StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), }, true - case upgrade_v1_20_1.UpgradeName: + case upgradeNameV1201: return UpgradeConfig{ - Handler: upgrade_v1_20_1.CreateUpgradeHandler(params), + Handler: standardUpgradeHandler(upgradeNameV1201, params), }, true // add future upgrades here diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index 7e45559f..f6629ca9 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -17,7 +17,6 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" - upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -46,7 +45,7 @@ func TestUpgradeNamesOrder(t *testing.T) { upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, - upgrade_v1_20_1.UpgradeName, + upgradeNameV1201, } require.Equal(t, expected, upgradeNames, "upgradeNames should stay in ascending order") } @@ -140,7 +139,7 @@ func TestV1200SkipsEVMInitGenesis(t *testing.T) { func TestV1201IsRegisteredAsMigrationOnlyUpgrade(t *testing.T) { params := newTestUpgradeParams("lumera-testnet-2") - config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) + config, found := SetupUpgrades(upgradeNameV1201, params) require.True(t, found) require.NotNil(t, config.Handler) require.Nil(t, config.StoreUpgrade, "v1.20.1 should not declare store changes") diff --git a/app/upgrades/v1_20_1/upgrade.go b/app/upgrades/v1_20_1/upgrade.go deleted file mode 100644 index 5cbfec4f..00000000 --- a/app/upgrades/v1_20_1/upgrade.go +++ /dev/null @@ -1,34 +0,0 @@ -package v1_20_1 - -import ( - "context" - "fmt" - - upgradetypes "cosmossdk.io/x/upgrade/types" - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/types/module" - - appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" -) - -// UpgradeName is the on-chain name used for this upgrade. -const UpgradeName = "v1.20.1" - -// CreateUpgradeHandler runs module migrations for the v1.20.1 hotfix release. -func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHandler { - return func(goCtx context.Context, _ upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { - p.Logger.Info(fmt.Sprintf("Starting upgrade %s...", UpgradeName)) - ctx := sdk.UnwrapSDKContext(goCtx) - - p.Logger.Info("Running module migrations...") - newVM, err := p.ModuleManager.RunMigrations(ctx, p.Configurator, fromVM) - if err != nil { - p.Logger.Error("Failed to run migrations", "error", err) - return nil, fmt.Errorf("failed to run migrations: %w", err) - } - p.Logger.Info("Module migrations completed.") - - p.Logger.Info(fmt.Sprintf("Successfully completed upgrade %s", UpgradeName)) - return newVM, nil - } -} From 816c1d833b34e7600153b18b8fc9e3af71f4cb58 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 12:25:41 -0400 Subject: [PATCH 12/28] =?UTF-8?q?evmigration:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20fail-loud=20estimate,=20env=20precedence,=20no=20sh?= =?UTF-8?q?adowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves three Copilot review comments on PR #184: - query.go: MigrationEstimate now propagates a redelegationsForValidator error instead of swallowing it. A corrupt src/dst index would otherwise undercount redelegations, shrink totalRecords, and could flip WouldSucceed to true for a validator whose real footprint exceeds MaxValidatorDelegations. - chain-helper.sh: LUMERA_* env values (documented as overrides) now count as explicitly set, so passing --network alongside them fills only the endpoints the caller left unset rather than clobbering the env values. Adds a bats test. - migrate_validator_scoped.go: rename redelegation loop variables that shadowed the Keeper receiver `k`. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/chain-helper.sh | 9 +++++++++ tests/scripts/chain-helper.bats | 19 +++++++++++++++++++ .../keeper/migrate_validator_scoped.go | 8 ++++---- x/evmigration/keeper/query.go | 13 ++++++++++--- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/scripts/chain-helper.sh b/scripts/chain-helper.sh index dcf37eaf..d181b723 100755 --- a/scripts/chain-helper.sh +++ b/scripts/chain-helper.sh @@ -31,9 +31,18 @@ JSON_OUTPUT=0 ALLOW_PARTIAL=0 GRPC_INSECURE="${LUMERA_GRPC_INSECURE:-0}" GRPC_MAX_TIME="${LUMERA_GRPC_MAX_TIME:-30}" + +# These track whether CHAIN_ID/NODE/GRPC were explicitly provided, so that +# apply_network_defaults() only fills endpoints the caller left unset. A value +# supplied via LUMERA_* env vars (seeded above) counts as explicit: env vars are +# documented as "overrides", so passing --network alongside them must NOT clobber +# them. An explicit --node/--grpc/--chain-id flag also sets these (see parsing). CHAIN_ID_FLAG_SET=0 NODE_FLAG_SET=0 GRPC_FLAG_SET=0 +if [[ -n "${LUMERA_CHAIN_ID:-}" ]]; then CHAIN_ID_FLAG_SET=1; fi +if [[ -n "${LUMERA_NODE:-}" || -n "${LUMERA_RPC:-}" ]]; then NODE_FLAG_SET=1; fi +if [[ -n "${LUMERA_GRPC:-}" ]]; then GRPC_FLAG_SET=1; fi usage() { cat <<'USAGE' diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index 8de75420..055a66f8 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -374,6 +374,25 @@ teardown() { ' } +@test "LUMERA_* env values are treated as explicit and survive --network" { + # Env vars are documented as overrides, so --network must fill only the + # endpoints the env didn't provide (here: grpc), not clobber the env values. + run env -u LUMERA_NODE -u LUMERA_GRPC \ + LUMERA_CHAIN_ID=env-chain LUMERA_RPC=tcp://env-rpc:26657 \ + "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ + --binary "$FAKE_LUMERAD" \ + --grpcurl "$FAKE_GRPCURL" \ + --network mainnet \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .chain_id == "env-chain" + and .rpc == "tcp://env-rpc:26657" + and .grpc == "grpc.lumera.io:443" + ' +} + @test "max-validator-delegations rejects --network without a value" { run "$SCRIPTS_DIR/chain-helper.sh" max-validator-delegations \ --binary "$FAKE_LUMERAD" \ diff --git a/x/evmigration/keeper/migrate_validator_scoped.go b/x/evmigration/keeper/migrate_validator_scoped.go index b9495b62..463bc00a 100644 --- a/x/evmigration/keeper/migrate_validator_scoped.go +++ b/x/evmigration/keeper/migrate_validator_scoped.go @@ -50,13 +50,13 @@ func (k Keeper) redelegationsForValidator(ctx sdk.Context, valAddr sdk.ValAddres // diverge queue bytes (and the app hash) across nodes. Sorting by the // redelegation store key also matches the IterateRedelegations fallback above. keys := make([]string, 0, len(seen)) - for k := range seen { - keys = append(keys, k) + for redKey := range seen { + keys = append(keys, redKey) } sort.Strings(keys) reds := make([]stakingtypes.Redelegation, 0, len(seen)) - for _, k := range keys { - reds = append(reds, seen[k]) + for _, redKey := range keys { + reds = append(reds, seen[redKey]) } return reds, nil } diff --git a/x/evmigration/keeper/query.go b/x/evmigration/keeper/query.go index f3cfbc6c..9758bd51 100644 --- a/x/evmigration/keeper/query.go +++ b/x/evmigration/keeper/query.go @@ -180,10 +180,17 @@ func (qs queryServer) MigrationEstimate(goCtx context.Context, req *types.QueryM resp.ValUnbondingCount = uint64(len(ubds)) } // Count redelegations where the validator is source OR destination - // (both are re-keyed during migration). - if reds, err := qs.k.redelegationsForValidator(ctx, valAddr); err == nil { - resp.ValRedelegationCount = uint64(len(reds)) + // (both are re-keyed during migration). Unlike the delegation/unbonding + // keeper reads above, the scoped redelegation lookup can fail on a + // corrupt src/dst index (an index row pointing at a missing record). We + // must NOT swallow that: undercounting redelegations would shrink + // totalRecords and could flip WouldSucceed to true for a validator whose + // real footprint exceeds MaxValidatorDelegations. Fail the estimate loudly. + reds, err := qs.k.redelegationsForValidator(ctx, valAddr) + if err != nil { + return nil, fmt.Errorf("count redelegations for validator %s: %w", valAddr, err) } + resp.ValRedelegationCount = uint64(len(reds)) // Surface the validator's BondStatus + Jailed flag so callers can // display the actionable cause when WouldSucceed is false. Jailed From 6192eff71ecf6097b423459fdf950ea0873326d3 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 15:13:09 -0400 Subject: [PATCH 13/28] fix(evmigration): scope MigrateActions via action secondary indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MigrateActions — and MigrateValidatorActions, which delegates to it — scanned the entire action store on every account/validator migration, burning gas proportional to the global action count rather than the address's own footprint. On testnet this drove ClaimLegacyAccount txs to ~1.5B gas each; under a mainnet block gas cap they would be unminable. Resolve the affected actions through the existing creator and supernode secondary indexes instead, unioning the two result sets and deduping by action ID so an action is re-keyed exactly once even when the legacy address is both its creator and a supernode. Adds ActionKeeper.GetActionsByCreator / GetActionsBySuperNode backed by the index prefixes, mirroring the scoped-iteration approach PR #184 already applied to the staking and distribution steps. Co-Authored-By: Claude Opus 4.7 --- x/action/v1/keeper/action.go | 40 ++++++++++ x/action/v1/keeper/action_by_index_test.go | 78 +++++++++++++++++++ x/evmigration/keeper/migrate_action.go | 52 ++++++++----- x/evmigration/keeper/migrate_test.go | 69 +++++++++++----- .../keeper/msg_server_claim_legacy_test.go | 23 +++--- .../msg_server_migrate_validator_test.go | 9 ++- x/evmigration/mocks/expected_keepers_mock.go | 62 +++++++++++---- x/evmigration/types/expected_keepers.go | 2 + 8 files changed, 269 insertions(+), 66 deletions(-) create mode 100644 x/action/v1/keeper/action_by_index_test.go diff --git a/x/action/v1/keeper/action.go b/x/action/v1/keeper/action.go index 68303f20..c9b0d965 100644 --- a/x/action/v1/keeper/action.go +++ b/x/action/v1/keeper/action.go @@ -515,6 +515,46 @@ func (k *Keeper) IterateActions(ctx sdk.Context, handler func(*actiontypes.Actio return nil } +// GetActionsByCreator returns every action whose Creator equals the given +// address, resolved through the creator secondary index rather than a +// full-store scan. +func (k *Keeper) GetActionsByCreator(ctx sdk.Context, creator string) ([]*actiontypes.Action, error) { + return k.getActionsByIndexPrefix(ctx, ActionByCreatorPrefix+creator+"/") +} + +// GetActionsBySuperNode returns every action that lists the given address in +// its SuperNodes field, resolved through the supernode secondary index rather +// than a full-store scan. +func (k *Keeper) GetActionsBySuperNode(ctx sdk.Context, supernode string) ([]*actiontypes.Action, error) { + return k.getActionsByIndexPrefix(ctx, ActionBySuperNodePrefix+supernode+"/") +} + +// getActionsByIndexPrefix scans a secondary-index prefix whose keys carry the +// action ID as their suffix and returns the resolved actions. +func (k *Keeper) getActionsByIndexPrefix(ctx sdk.Context, indexPrefix string) ([]*actiontypes.Action, error) { + store := k.storeService.OpenKVStore(ctx) + prefixBytes := []byte(indexPrefix) + + iter, err := store.Iterator(prefixBytes, storetypes.PrefixEndBytes(prefixBytes)) + if err != nil { + return nil, errors.Wrap(err, "failed to create iterator for action index") + } + defer func() { _ = iter.Close() }() + + var actions []*actiontypes.Action + for ; iter.Valid(); iter.Next() { + actionID := string(iter.Key()[len(prefixBytes):]) + action, found := k.GetActionByID(ctx, actionID) + if !found { + // Stale or corrupted index entry; skip but keep scanning. + k.Logger().Error("action referenced in index not found", "action_id", actionID, "index_prefix", indexPrefix) + continue + } + actions = append(actions, action) + } + return actions, nil +} + // IterateActionsByState iterates over actions with a specific state func (k *Keeper) IterateActionsByState(ctx sdk.Context, state actiontypes.ActionState, handler func(*actiontypes.Action) bool) error { store := k.storeService.OpenKVStore(ctx) diff --git a/x/action/v1/keeper/action_by_index_test.go b/x/action/v1/keeper/action_by_index_test.go new file mode 100644 index 00000000..d78414fb --- /dev/null +++ b/x/action/v1/keeper/action_by_index_test.go @@ -0,0 +1,78 @@ +package keeper_test + +import ( + "testing" + + keepertest "github.com/LumeraProtocol/lumera/testutil/keeper" + "github.com/LumeraProtocol/lumera/testutil/sample" + "github.com/LumeraProtocol/lumera/x/action/v1/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func actionIDSet(actions []*types.Action) map[string]struct{} { + set := make(map[string]struct{}, len(actions)) + for _, a := range actions { + set[a.ActionID] = struct{}{} + } + return set +} + +func TestKeeper_GetActionsByCreator(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + k, ctx := keepertest.ActionKeeper(t, ctrl) + + creatorA := sample.AccAddress() + creatorB := sample.AccAddress() + snX := sample.AccAddress() + price := sdk.NewInt64Coin("ulume", 100).String() + + // creatorA owns actions 1 and 2; creatorB owns action 3. + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creatorA, ActionID: "1", ActionType: types.ActionTypeCascade, Price: price, State: types.ActionStatePending, BlockHeight: 10, SuperNodes: []string{snX}})) + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creatorA, ActionID: "2", ActionType: types.ActionTypeSense, Price: price, State: types.ActionStatePending, BlockHeight: 11, SuperNodes: []string{snX}})) + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creatorB, ActionID: "3", ActionType: types.ActionTypeCascade, Price: price, State: types.ActionStatePending, BlockHeight: 12, SuperNodes: []string{snX}})) + + got, err := k.GetActionsByCreator(ctx, creatorA) + require.NoError(t, err) + require.Len(t, got, 2) + ids := actionIDSet(got) + require.Contains(t, ids, "1") + require.Contains(t, ids, "2") + require.NotContains(t, ids, "3") + + // Address that created nothing yields an empty (non-error) result. + empty, err := k.GetActionsByCreator(ctx, sample.AccAddress()) + require.NoError(t, err) + require.Len(t, empty, 0) +} + +func TestKeeper_GetActionsBySuperNode(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + k, ctx := keepertest.ActionKeeper(t, ctrl) + + creator := sample.AccAddress() + snX := sample.AccAddress() + snY := sample.AccAddress() + price := sdk.NewInt64Coin("ulume", 100).String() + + // snX serves actions 1 and 3; snY serves action 2. + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creator, ActionID: "1", ActionType: types.ActionTypeCascade, Price: price, State: types.ActionStatePending, BlockHeight: 10, SuperNodes: []string{snX}})) + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creator, ActionID: "2", ActionType: types.ActionTypeSense, Price: price, State: types.ActionStatePending, BlockHeight: 11, SuperNodes: []string{snY}})) + require.NoError(t, k.SetAction(ctx, &types.Action{Creator: creator, ActionID: "3", ActionType: types.ActionTypeCascade, Price: price, State: types.ActionStatePending, BlockHeight: 12, SuperNodes: []string{snX, snY}})) + + got, err := k.GetActionsBySuperNode(ctx, snX) + require.NoError(t, err) + require.Len(t, got, 2) + ids := actionIDSet(got) + require.Contains(t, ids, "1") + require.Contains(t, ids, "3") + require.NotContains(t, ids, "2") + + // Address serving nothing yields an empty (non-error) result. + empty, err := k.GetActionsBySuperNode(ctx, sample.AccAddress()) + require.NoError(t, err) + require.Len(t, empty, 0) +} diff --git a/x/evmigration/keeper/migrate_action.go b/x/evmigration/keeper/migrate_action.go index a2f90a1c..b3914b0c 100644 --- a/x/evmigration/keeper/migrate_action.go +++ b/x/evmigration/keeper/migrate_action.go @@ -6,39 +6,53 @@ import ( actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" ) -// MigrateActions updates action records where legacyAddr is the creator or -// is listed in the SuperNodes field (which stores AccAddress, not ValAddress). +// MigrateActions updates action records where legacyAddr is the creator or is +// listed in the SuperNodes field (which stores AccAddress, not ValAddress). +// +// Rather than scanning the entire action store, it resolves the affected +// actions through the creator and supernode secondary indexes, so cost scales +// with the number of actions this address touches, not the global action count. func (k Keeper) MigrateActions(ctx sdk.Context, legacyAddr, newAddr sdk.AccAddress) error { legacyStr := legacyAddr.String() newStr := newAddr.String() - var toUpdate []*actiontypes.Action + byCreator, err := k.actionKeeper.GetActionsByCreator(ctx, legacyStr) + if err != nil { + return err + } + bySuperNode, err := k.actionKeeper.GetActionsBySuperNode(ctx, legacyStr) + if err != nil { + return err + } - err := k.actionKeeper.IterateActions(ctx, func(action *actiontypes.Action) bool { - modified := false + // An action may reference the legacy address as both creator and supernode, + // and each index lookup returns an independent copy. Dedupe by action ID so + // the record is written exactly once, preserving encounter order. + seen := make(map[string]*actiontypes.Action, len(byCreator)+len(bySuperNode)) + order := make([]string, 0, len(byCreator)+len(bySuperNode)) + for _, action := range byCreator { + if _, ok := seen[action.ActionID]; !ok { + seen[action.ActionID] = action + order = append(order, action.ActionID) + } + } + for _, action := range bySuperNode { + if _, ok := seen[action.ActionID]; !ok { + seen[action.ActionID] = action + order = append(order, action.ActionID) + } + } + for _, id := range order { + action := seen[id] if action.Creator == legacyStr { action.Creator = newStr - modified = true } - for i, sn := range action.SuperNodes { if sn == legacyStr { action.SuperNodes[i] = newStr - modified = true } } - - if modified { - toUpdate = append(toUpdate, action) - } - return false - }) - if err != nil { - return err - } - - for _, action := range toUpdate { if err := k.actionKeeper.SetAction(ctx, action); err != nil { return err } diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index d3b7e2e0..86a97be8 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -1159,32 +1159,70 @@ func TestMigrateSupernode_NotFound(t *testing.T) { // --- MigrateActions tests --- -// TestMigrateActions_CreatorAndSuperNodes verifies that both the Creator field -// and SuperNodes array entries are updated from legacy to new address. +// TestMigrateActions_CreatorAndSuperNodes verifies that when the legacy address +// is both the creator and a supernode of the same action, the action is updated +// exactly once (deduped across the creator and supernode indexes) with both the +// Creator field and the matching SuperNodes entry re-keyed to the new address. func TestMigrateActions_CreatorAndSuperNodes(t *testing.T) { f := initMockFixture(t) legacy := testAccAddr() newAddr := testAccAddr() otherAddr := testAccAddr() - action := &actiontypes.Action{ + // Each index lookup resolves an independent copy of the same action, mirroring + // the real keeper which unmarshals a fresh Action on every GetActionByID. + byCreator := &actiontypes.Action{ + ActionID: "action-1", + Creator: legacy.String(), + SuperNodes: []string{legacy.String(), otherAddr.String()}, + } + bySuperNode := &actiontypes.Action{ ActionID: "action-1", Creator: legacy.String(), SuperNodes: []string{legacy.String(), otherAddr.String()}, } - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, cb func(*actiontypes.Action) bool) error { - cb(action) - return nil - }) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), legacy.String()). + Return([]*actiontypes.Action{byCreator}, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), legacy.String()). + Return([]*actiontypes.Action{bySuperNode}, nil) f.actionKeeper.EXPECT().SetAction(gomock.Any(), gomock.Any()). DoAndReturn(func(_ any, updated *actiontypes.Action) error { + require.Equal(t, "action-1", updated.ActionID) require.Equal(t, newAddr.String(), updated.Creator) require.Equal(t, newAddr.String(), updated.SuperNodes[0]) require.Equal(t, otherAddr.String(), updated.SuperNodes[1]) return nil - }) + }).Times(1) + + err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) + require.NoError(t, err) +} + +// TestMigrateActions_SuperNodeOnly verifies that an action where the legacy +// address appears only as a supernode (with a different creator) has just its +// SuperNodes entry re-keyed, leaving the Creator field untouched. +func TestMigrateActions_SuperNodeOnly(t *testing.T) { + f := initMockFixture(t) + legacy := testAccAddr() + newAddr := testAccAddr() + creator := testAccAddr() + + action := &actiontypes.Action{ + ActionID: "action-2", + Creator: creator.String(), + SuperNodes: []string{legacy.String()}, + } + + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), legacy.String()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), legacy.String()). + Return([]*actiontypes.Action{action}, nil) + f.actionKeeper.EXPECT().SetAction(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, updated *actiontypes.Action) error { + require.Equal(t, creator.String(), updated.Creator) + require.Equal(t, newAddr.String(), updated.SuperNodes[0]) + return nil + }).Times(1) err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) require.NoError(t, err) @@ -1196,16 +1234,9 @@ func TestMigrateActions_NoMatch(t *testing.T) { legacy := testAccAddr() newAddr := testAccAddr() - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, cb func(*actiontypes.Action) bool) error { - // No actions match legacy address. - cb(&actiontypes.Action{ - ActionID: "action-1", - Creator: testAccAddr().String(), - SuperNodes: []string{testAccAddr().String()}, - }) - return nil - }) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), legacy.String()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), legacy.String()).Return(nil, nil) + // No SetAction expected: nothing references the legacy address. err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) require.NoError(t, err) diff --git a/x/evmigration/keeper/msg_server_claim_legacy_test.go b/x/evmigration/keeper/msg_server_claim_legacy_test.go index 750d491f..23f536fa 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -486,7 +486,8 @@ func TestClaimLegacyAccount_Success(t *testing.T) { ) // Step 7: MigrateActions — no matching actions. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // Step 8: MigrateClaim — no claim records targeting this address. f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) @@ -582,7 +583,8 @@ func TestClaimLegacyAccount_MigratedThirdPartyWithdrawAddress(t *testing.T) { f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( sntypes.SuperNode{}, false, nil, ) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) msg := &types.MsgClaimLegacyAccount{ @@ -865,8 +867,8 @@ func TestClaimLegacyAccount_FailAtActions(t *testing.T) { ) // Step 7: MigrateActions fails. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return( - fmt.Errorf("action store corrupted"), + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return( + nil, fmt.Errorf("action store corrupted"), ) _, err := f.msgServer.ClaimLegacyAccount(f.ctx, msg) @@ -902,7 +904,8 @@ func TestClaimLegacyAccount_FailAtClaim(t *testing.T) { f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( sntypes.SuperNode{}, false, nil, ) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // Step 8: MigrateClaim fails. f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return( @@ -1157,8 +1160,8 @@ func TestMigrateValidator_FailAtValidatorActions(t *testing.T) { f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) // Step V6: action re-key fails. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return( - fmt.Errorf("action store corrupted"), + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return( + nil, fmt.Errorf("action store corrupted"), ) _, err := f.msgServer.MigrateValidator(f.ctx, msg) @@ -1178,7 +1181,8 @@ func TestMigrateValidator_FailAtAuth(t *testing.T) { // V5-V6: no supernode, no actions. f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // Step V7: MigrateDistribution + MigrateStaking succeed before MigrateAuth fails. // Snapshot withdraw addr. @@ -1274,7 +1278,8 @@ func TestClaimLegacyAccount_WithDelegations(t *testing.T) { f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( sntypes.SuperNode{}, false, nil, ) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) msg := &types.MsgClaimLegacyAccount{ diff --git a/x/evmigration/keeper/msg_server_migrate_validator_test.go b/x/evmigration/keeper/msg_server_migrate_validator_test.go index 921db9f0..26339766 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator_test.go +++ b/x/evmigration/keeper/msg_server_migrate_validator_test.go @@ -299,7 +299,8 @@ func TestMigrateValidator_Success(t *testing.T) { f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) // Step V6: MigrateValidatorActions — no matching actions. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // Step V7: Account-level migration. // Snapshot withdraw address. @@ -457,7 +458,8 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) // V6: no actions. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // --- Step V7: The key part of this test --- // Operator has a delegation to otherValAddr. MigrateDistribution and @@ -658,7 +660,8 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), oldValAddr).Return(sntypes.SuperNode{}, false) // Actions — no action references. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()) + f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) + f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) // Step V7 account-level migration: MigrateDistribution + MigrateStaking + Auth/Bank/etc. // Snapshot withdraw address. diff --git a/x/evmigration/mocks/expected_keepers_mock.go b/x/evmigration/mocks/expected_keepers_mock.go index 5e757878..23a876f4 100644 --- a/x/evmigration/mocks/expected_keepers_mock.go +++ b/x/evmigration/mocks/expected_keepers_mock.go @@ -2,11 +2,11 @@ // // Code generated by MockGen. DO NOT EDIT. -// Source: x/evmigration/types/expected_keepers.go +// Source: expected_keepers.go // // Generated by this command: // -// mockgen -copyright_file=testutil/mock_header.txt -destination=x/evmigration/mocks/expected_keepers_mock.go -package=evmigrationmocks -source=x/evmigration/types/expected_keepers.go +// mockgen -copyright_file=../../../testutil/mock_header.txt -destination=../mocks/expected_keepers_mock.go -package=evmigrationmocks -source=expected_keepers.go // // Package evmigrationmocks is a generated GoMock package. @@ -309,20 +309,6 @@ func (mr *MockStakingKeeperMockRecorder) GetRedelegations(ctx, delegator, maxRet return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRedelegations", reflect.TypeOf((*MockStakingKeeper)(nil).GetRedelegations), ctx, delegator, maxRetrieve) } -// IterateRedelegations mocks base method. -func (m *MockStakingKeeper) IterateRedelegations(ctx context.Context, fn func(index int64, red types4.Redelegation) bool) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IterateRedelegations", ctx, fn) - ret0, _ := ret[0].(error) - return ret0 -} - -// IterateRedelegations indicates an expected call of IterateRedelegations. -func (mr *MockStakingKeeperMockRecorder) IterateRedelegations(ctx, fn any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateRedelegations", reflect.TypeOf((*MockStakingKeeper)(nil).IterateRedelegations), ctx, fn) -} - // GetRedelegationsFromSrcValidator mocks base method. func (m *MockStakingKeeper) GetRedelegationsFromSrcValidator(ctx context.Context, valAddr types2.ValAddress) ([]types4.Redelegation, error) { m.ctrl.T.Helper() @@ -441,6 +427,20 @@ func (mr *MockStakingKeeperMockRecorder) InsertUBDQueue(ctx, ubd, completionTime return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertUBDQueue", reflect.TypeOf((*MockStakingKeeper)(nil).InsertUBDQueue), ctx, ubd, completionTime) } +// IterateRedelegations mocks base method. +func (m *MockStakingKeeper) IterateRedelegations(ctx context.Context, fn func(int64, types4.Redelegation) bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IterateRedelegations", ctx, fn) + ret0, _ := ret[0].(error) + return ret0 +} + +// IterateRedelegations indicates an expected call of IterateRedelegations. +func (mr *MockStakingKeeperMockRecorder) IterateRedelegations(ctx, fn any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateRedelegations", reflect.TypeOf((*MockStakingKeeper)(nil).IterateRedelegations), ctx, fn) +} + // RemoveDelegation mocks base method. func (m *MockStakingKeeper) RemoveDelegation(ctx context.Context, delegation types4.Delegation) error { m.ctrl.T.Helper() @@ -1247,6 +1247,36 @@ func (mr *MockActionKeeperMockRecorder) GetActionByID(ctx, actionID any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionByID", reflect.TypeOf((*MockActionKeeper)(nil).GetActionByID), ctx, actionID) } +// GetActionsByCreator mocks base method. +func (m *MockActionKeeper) GetActionsByCreator(ctx types2.Context, creator string) ([]*types.Action, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActionsByCreator", ctx, creator) + ret0, _ := ret[0].([]*types.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActionsByCreator indicates an expected call of GetActionsByCreator. +func (mr *MockActionKeeperMockRecorder) GetActionsByCreator(ctx, creator any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionsByCreator", reflect.TypeOf((*MockActionKeeper)(nil).GetActionsByCreator), ctx, creator) +} + +// GetActionsBySuperNode mocks base method. +func (m *MockActionKeeper) GetActionsBySuperNode(ctx types2.Context, supernode string) ([]*types.Action, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActionsBySuperNode", ctx, supernode) + ret0, _ := ret[0].([]*types.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActionsBySuperNode indicates an expected call of GetActionsBySuperNode. +func (mr *MockActionKeeperMockRecorder) GetActionsBySuperNode(ctx, supernode any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionsBySuperNode", reflect.TypeOf((*MockActionKeeper)(nil).GetActionsBySuperNode), ctx, supernode) +} + // IterateActions mocks base method. func (m *MockActionKeeper) IterateActions(ctx types2.Context, handler func(*types.Action) bool) error { m.ctrl.T.Helper() diff --git a/x/evmigration/types/expected_keepers.go b/x/evmigration/types/expected_keepers.go index 7955cdc0..bc9af9c1 100644 --- a/x/evmigration/types/expected_keepers.go +++ b/x/evmigration/types/expected_keepers.go @@ -133,6 +133,8 @@ type SupernodeKeeper interface { // ActionKeeper defines the expected interface for the x/action module. type ActionKeeper interface { IterateActions(ctx sdk.Context, handler func(*actiontypes.Action) bool) error + GetActionsByCreator(ctx sdk.Context, creator string) ([]*actiontypes.Action, error) + GetActionsBySuperNode(ctx sdk.Context, supernode string) ([]*actiontypes.Action, error) SetAction(ctx sdk.Context, action *actiontypes.Action) error GetActionByID(ctx sdk.Context, actionID string) (*actiontypes.Action, bool) } From 9790141f750c96f96d449f1d55bc97d3eede8366 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Fri, 3 Jul 2026 15:46:51 -0400 Subject: [PATCH 14/28] fix(evmigration): store delegation stake as tokens, not shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DelegatorStartingInfo.Stake must hold tokens (TokensFromSharesTruncated), matching the SDK's initializeDelegation. Both migration re-key paths wrote raw shares. For any ever-slashed validator (exchange rate < 1, which passes the only-currently-jailed pre-check), shares overstate the real stake, so the next WithdrawDelegatorReward/undelegate/redelegate from any delegator panics ("calculated final stake greater than current stake") — permanently bricking those operations for every affected delegator. Fetch the target validator and convert shares -> tokens in both MigrateValidatorDelegations (once, outside the loop — all delegations share the re-keyed validator) and migrateActiveDelegations (per delegation, since each targets a different third-party validator). Co-Authored-By: Claude Opus 4.7 --- x/evmigration/keeper/migrate_staking.go | 9 +- x/evmigration/keeper/migrate_test.go | 124 ++++++++++++++++++ x/evmigration/keeper/migrate_validator.go | 11 +- .../keeper/msg_server_claim_legacy_test.go | 4 + .../msg_server_migrate_validator_test.go | 18 +++ 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/x/evmigration/keeper/migrate_staking.go b/x/evmigration/keeper/migrate_staking.go index cc58f732..9eba4322 100644 --- a/x/evmigration/keeper/migrate_staking.go +++ b/x/evmigration/keeper/migrate_staking.go @@ -69,10 +69,17 @@ func (k Keeper) migrateActiveDelegations(ctx sdk.Context, legacyAddr, newAddr sd } sdkCtx := sdk.UnwrapSDKContext(ctx) previousPeriod := currentRewards.Period - 1 + // Distribution stores stake as tokens (TokensFromSharesTruncated), not + // raw shares; for an ever-slashed validator (exchange rate < 1) storing + // shares overstates stake and panics the next reward/undelegate tx. + val, err := k.stakingKeeper.GetValidator(ctx, valAddr) + if err != nil { + return err + } startingInfo := distrtypes.DelegatorStartingInfo{ Height: uint64(sdkCtx.BlockHeight()), PreviousPeriod: previousPeriod, - Stake: del.Shares, + Stake: val.TokensFromSharesTruncated(del.Shares), } if err := k.incrementHistoricalRewardsReferenceCount(ctx, valAddr, previousPeriod); err != nil { return err diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index 86a97be8..210a1a96 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -1309,6 +1309,10 @@ func TestMigrateStaking_ActiveDelegations(t *testing.T) { f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), valAddr).Return(distrtypes.ValidatorCurrentRewards{Period: 5}, nil) expectHistoricalRewardsIncrement(f.distributionKeeper, valAddr, 4, 1) + // migrateActiveDelegations fetches the validator to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{OperatorAddress: valAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), valAddr, newAddr, gomock.Any()).Return(nil) // migrateUnbondingDelegations @@ -1324,6 +1328,59 @@ func TestMigrateStaking_ActiveDelegations(t *testing.T) { require.NoError(t, err) } +// TestMigrateStaking_SlashedValidatorStoresTokensNotShares verifies the account +// migration path stores DelegatorStartingInfo.Stake as tokens +// (val.TokensFromSharesTruncated(shares)), not raw shares — the same SDK +// invariant as the validator path. For a delegation to a slashed validator +// (exchange rate < 1) storing shares would panic the reward math on the +// delegator's next withdraw/undelegate/redelegate. +func TestMigrateStaking_SlashedValidatorStoresTokensNotShares(t *testing.T) { + f := initMockFixture(t) + legacy := testAccAddr() + newAddr := testAccAddr() + valAddr := sdk.ValAddress(testAccAddr()) + + del := stakingtypes.NewDelegation(legacy.String(), valAddr.String(), math.LegacyNewDec(100)) + + // Slashed validator: 90 tokens / 100 shares → TokensFromSharesTruncated(100) = 90. + slashedVal := stakingtypes.Validator{ + OperatorAddress: valAddr.String(), + Tokens: math.NewInt(90), + DelegatorShares: math.LegacyNewDec(100), + } + expectedStake := slashedVal.TokensFromSharesTruncated(del.Shares) + + // migrateActiveDelegations + f.stakingKeeper.EXPECT().GetDelegatorDelegations(gomock.Any(), legacy, ^uint16(0)).Return([]stakingtypes.Delegation{del}, nil) + f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), valAddr, legacy).Return(nil) + f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), del).Return(nil) + f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), valAddr).Return(distrtypes.ValidatorCurrentRewards{Period: 5}, nil) + expectHistoricalRewardsIncrement(f.distributionKeeper, valAddr, 4, 1) + // The validator must be fetched to convert shares → tokens. + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return(slashedVal, nil) + + var capturedStake math.LegacyDec + f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), valAddr, newAddr, gomock.Any()).DoAndReturn( + func(_ sdk.Context, _ sdk.ValAddress, _ sdk.AccAddress, info distrtypes.DelegatorStartingInfo) error { + capturedStake = info.Stake + return nil + }, + ) + + // migrateUnbondingDelegations / migrateRedelegations — none. + f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), legacy, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetRedelegations(gomock.Any(), legacy, ^uint16(0)).Return(nil, nil) + + // migrateWithdrawAddress — origWithdrawAddr is legacy (self). + f.distributionKeeper.EXPECT().SetDelegatorWithdrawAddr(gomock.Any(), newAddr, newAddr).Return(nil) + + err := f.keeper.MigrateStaking(f.ctx, legacy, newAddr, legacy) + require.NoError(t, err) + require.Equal(t, expectedStake, capturedStake) + require.True(t, capturedStake.LT(del.Shares), "stake must be tokens (< shares) for a slashed validator") +} + // TestMigrateStaking_NoDelegations verifies no-op when delegator has no delegations. func TestMigrateStaking_NoDelegations(t *testing.T) { f := initMockFixture(t) @@ -1418,6 +1475,10 @@ func TestMigrateStaking_WithUnbondingDelegation(t *testing.T) { f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), valAddr).Return(distrtypes.ValidatorCurrentRewards{Period: 5}, nil) expectHistoricalRewardsIncrement(f.distributionKeeper, valAddr, 4, 1) + // migrateActiveDelegations fetches the validator to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{OperatorAddress: valAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), valAddr, newAddr, gomock.Any()).Return(nil) // migrateUnbondingDelegations @@ -1476,6 +1537,10 @@ func TestMigrateStaking_WithRedelegation(t *testing.T) { f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), srcValAddr).Return(distrtypes.ValidatorCurrentRewards{Period: 3}, nil) expectHistoricalRewardsIncrement(f.distributionKeeper, srcValAddr, 2, 1) + // migrateActiveDelegations fetches the validator to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), srcValAddr).Return( + stakingtypes.Validator{OperatorAddress: srcValAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), srcValAddr, newAddr, gomock.Any()).Return(nil) // migrateUnbondingDelegations @@ -1970,6 +2035,11 @@ func TestMigrateValidatorDelegations_SetsHistoricalRewardsRefCountOnce(t *testin }, ).Times(1) + // V4 fetches the re-keyed validator once to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return( + stakingtypes.Validator{OperatorAddress: newValAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) + // Per-delegation re-keying: no refcount call inside the loop. Each delegation's // fresh starting info must reference the target period. f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, gomock.Any()).Return(nil).Times(3) @@ -1989,6 +2059,60 @@ func TestMigrateValidatorDelegations_SetsHistoricalRewardsRefCountOnce(t *testin require.Equal(t, uint32(4), capturedRefCount) } +// TestMigrateValidatorDelegations_SlashedValidatorStoresTokensNotShares verifies +// the re-keyed DelegatorStartingInfo.Stake holds tokens +// (val.TokensFromSharesTruncated(shares)), not raw shares. The SDK stores stake +// as tokens (x/distribution initializeDelegation); for a validator whose exchange +// rate dropped below 1 — any validator ever slashed, which passes the +// only-currently-jailed pre-check — shares overstate the real stake, so storing +// shares makes CalculateDelegationRewards panic ("calculated final stake greater +// than current stake") on every delegator's next withdraw/undelegate/redelegate. +func TestMigrateValidatorDelegations_SlashedValidatorStoresTokensNotShares(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + + oldValAddr := sdk.ValAddress(testAccAddr()) + newValAddr := sdk.ValAddress(testAccAddr()) + + del := stakingtypes.NewDelegation(testAccAddr().String(), oldValAddr.String(), math.LegacyNewDec(100)) + + // Slashed validator: 90 tokens back 100 shares (exchange rate 0.9), so + // TokensFromSharesTruncated(100) = 90, strictly less than the 100 shares. + slashedVal := stakingtypes.Validator{ + OperatorAddress: newValAddr.String(), + Tokens: math.NewInt(90), + DelegatorShares: math.LegacyNewDec(100), + } + expectedStake := slashedVal.TokensFromSharesTruncated(del.Shares) + + const targetPeriod = uint64(4) + f.distributionKeeper.EXPECT().GetValidatorCurrentRewards(gomock.Any(), newValAddr).Return( + distrtypes.ValidatorCurrentRewards{Period: 5}, nil, + ) + f.writeValidatorHistoricalRewards(newValAddr, targetPeriod, distrtypes.ValidatorHistoricalRewards{ReferenceCount: 1}) + f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil).Times(1) + + // The re-keyed validator must be fetched to convert shares → tokens. + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return(slashedVal, nil) + + f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, gomock.Any()).Return(nil) + f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), gomock.Any()).Return(nil) + f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) + + var capturedStake math.LegacyDec + f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), newValAddr, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ sdk.Context, _ sdk.ValAddress, _ sdk.AccAddress, info distrtypes.DelegatorStartingInfo) error { + capturedStake = info.Stake + return nil + }, + ) + + err := f.keeper.MigrateValidatorDelegations(f.ctx, oldValAddr, newValAddr, []stakingtypes.Delegation{del}, nil, nil) + require.NoError(t, err) + require.Equal(t, expectedStake, capturedStake) + require.True(t, capturedStake.LT(del.Shares), "stake must be tokens (< shares) for a slashed validator") +} + func TestMigrateValidatorDistribution_RekeysAllPeriodsAndSlashEvents(t *testing.T) { f := initMockFixture(t) f.wireScopedMigrationStores() diff --git a/x/evmigration/keeper/migrate_validator.go b/x/evmigration/keeper/migrate_validator.go index 3c4468fa..a79cbeb2 100644 --- a/x/evmigration/keeper/migrate_validator.go +++ b/x/evmigration/keeper/migrate_validator.go @@ -81,6 +81,7 @@ func (k Keeper) MigrateValidatorDelegations( // single write here instead of resetting to 1 and incrementing once per // delegation, which cost N+1 full-chain scans of ValidatorHistoricalRewards. var targetPeriod uint64 + var val stakingtypes.Validator if len(delegations) > 0 { currentRewards, err := k.distributionKeeper.GetValidatorCurrentRewards(ctx, newValAddr) if err != nil { @@ -90,6 +91,14 @@ func (k Keeper) MigrateValidatorDelegations( if err := k.setHistoricalRewardsReferenceCount(ctx, newValAddr, targetPeriod, uint32(1+len(delegations))); err != nil { return err } + // Fetch the re-keyed validator once to convert shares to tokens below. + // Distribution stores stake as tokens (TokensFromSharesTruncated), not + // raw shares; for an ever-slashed validator (exchange rate < 1) storing + // shares overstates stake and panics the next reward/undelegate tx. + val, err = k.stakingKeeper.GetValidator(ctx, newValAddr) + if err != nil { + return err + } } for _, del := range delegations { @@ -120,7 +129,7 @@ func (k Keeper) MigrateValidatorDelegations( startingInfo := distrtypes.DelegatorStartingInfo{ PreviousPeriod: targetPeriod, Height: uint64(ctx.BlockHeight()), - Stake: del.Shares, + Stake: val.TokensFromSharesTruncated(del.Shares), } if err := k.distributionKeeper.SetDelegatorStartingInfo(ctx, newValAddr, delAddr, startingInfo); err != nil { return err diff --git a/x/evmigration/keeper/msg_server_claim_legacy_test.go b/x/evmigration/keeper/msg_server_claim_legacy_test.go index 23f536fa..87a20b8a 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -1249,6 +1249,10 @@ func TestClaimLegacyAccount_WithDelegations(t *testing.T) { distrtypes.ValidatorCurrentRewards{Period: 5}, nil, ) expectHistoricalRewardsIncrement(f.distributionKeeper, valAddr, 4, 1) + // migrateActiveDelegations fetches the validator to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{OperatorAddress: valAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), valAddr, newAddr, gomock.Any()).Return(nil) // migrateUnbondingDelegations diff --git a/x/evmigration/keeper/msg_server_migrate_validator_test.go b/x/evmigration/keeper/msg_server_migrate_validator_test.go index 26339766..dbf14c9e 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator_test.go +++ b/x/evmigration/keeper/msg_server_migrate_validator_test.go @@ -289,6 +289,11 @@ func TestMigrateValidator_Success(t *testing.T) { distrtypes.ValidatorCurrentRewards{Period: 3}, nil, ) expectHistoricalRewardsSet(f.distributionKeeper, newValAddr, 2, 2) + // V4 fetches the re-keyed validator to convert shares → tokens; a rate-1.0 + // validator (tokens == shares) keeps Stake == shares for the assertion below. + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return( + stakingtypes.Validator{OperatorAddress: newValAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) // Per-delegation re-keying (no per-delegation refcount bump — set once above). f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, legacyAddr).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), del).Return(nil) @@ -449,6 +454,10 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { }, ) f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil) + // V4 fetches the re-keyed validator to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return( + stakingtypes.Validator{OperatorAddress: newValAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, legacyAddr).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), selfDel).Return(nil) f.stakingKeeper.EXPECT().SetDelegation(gomock.Any(), gomock.Any()).Return(nil) @@ -506,6 +515,10 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { }, ) f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), otherValAddr, otherTargetPeriod, gomock.Any()).Return(nil) + // migrateActiveDelegations fetches otherValAddr to convert shares → tokens (rate 1.0). + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), otherValAddr).Return( + stakingtypes.Validator{OperatorAddress: otherValAddr.String(), Tokens: math.NewInt(50), DelegatorShares: math.LegacyNewDec(50)}, nil, + ) f.distributionKeeper.EXPECT().SetDelegatorStartingInfo(gomock.Any(), otherValAddr, newAddr, gomock.Any()).Return(nil) // No unbonding delegations or redelegations to other validators. f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), legacyAddr, ^uint16(0)).Return(nil, nil) @@ -647,6 +660,11 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { }, ) f.distributionKeeper.EXPECT().SetValidatorHistoricalRewards(gomock.Any(), newValAddr, targetPeriod, gomock.Any()).Return(nil) + // V4 fetches the re-keyed validator once (outside the loop) to convert + // shares → tokens; rate 1.0 keeps each delegation's Stake == shares. + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return( + stakingtypes.Validator{OperatorAddress: newValAddr.String(), Tokens: math.NewInt(100), DelegatorShares: math.LegacyNewDec(100)}, nil, + ) for range 2 { f.distributionKeeper.EXPECT().DeleteDelegatorStartingInfo(gomock.Any(), oldValAddr, gomock.Any()).Return(nil) f.stakingKeeper.EXPECT().RemoveDelegation(gomock.Any(), gomock.Any()).Return(nil) From 5a72b211b9fc99108e6cf48f9492a50d60864c70 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 09:49:59 -0400 Subject: [PATCH 15/28] Skip claim records in evmigration devnet verify --- devnet/tests/evmigration/verify.go | 14 ++------------ docs/evm-integration/evmigration/devnet-tests.md | 8 +++----- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/devnet/tests/evmigration/verify.go b/devnet/tests/evmigration/verify.go index 6ba3af75..a47c26a4 100644 --- a/devnet/tests/evmigration/verify.go +++ b/devnet/tests/evmigration/verify.go @@ -1,6 +1,6 @@ // verify.go implements the "verify" mode, which scans all migrated legacy // addresses and checks that no leftover state references remain across bank, -// staking, distribution, authz, feegrant, action, claim, and supernode modules. +// staking, distribution, authz, feegrant, action, and supernode modules. package main import ( @@ -34,7 +34,7 @@ func runVerify() { log.Println("no migrated legacy addresses to verify") return } - log.Printf("verifying %d migrated legacy addresses across all chain modules (except evmigration)", len(targets)) + log.Printf("verifying %d migrated legacy addresses across chain modules (skipping evmigration and claim state)", len(targets)) var issues []issue addIssue := func(t verifyTarget, module, detail string) { @@ -121,16 +121,6 @@ func runVerify() { len(ids), strings.Join(ids, ", "))) } - // ── claim: claim record pointing to legacy ──────────────────── - if claimed, destAddr, _, err := queryClaimRecord(t.legacyAddr); err == nil { - if !claimed { - addIssue(t, "claim", "unclaimed claim record still exists for legacy address") - } else if destAddr == t.legacyAddr { - addIssue(t, "claim", "claim record dest_address still points to legacy address") - } - } - // claim query errors are expected (no record = good) - // ── evmigration: migration record must exist ────────────────── hasMigRecord, recordNewAddr := queryMigrationRecord(t.legacyAddr) if !hasMigRecord { diff --git a/docs/evm-integration/evmigration/devnet-tests.md b/docs/evm-integration/evmigration/devnet-tests.md index fa23b79f..c0f974f6 100644 --- a/docs/evm-integration/evmigration/devnet-tests.md +++ b/docs/evm-integration/evmigration/devnet-tests.md @@ -22,7 +22,6 @@ The migration touches many modules. The test tool verifies correct re-keying acr | **x/feegrant** | Fee allowance re-creation (both granter and grantee) | | **x/supernode** | `ValidatorAddress`, `SupernodeAccount`, `Evidence`, `PrevSupernodeAccounts`, `MetricsState` | | **x/action** | `Creator` and `SuperNodes` fields in action records | -| **x/claim** | `DestAddress` in claim records | | **x/evmigration** | Core migration logic, dual-signature verification, rate limiting, params | Two custom ante decorators support the migration: @@ -161,7 +160,7 @@ This is the default mode used by `make devnet-evm-upgrade`. ### 6. `verify` — Verify No Leftover Legacy State (Post-Migration) -Run **after** all migrations complete. Queries every chain module (except `x/evmigration` itself) via RPC to confirm that no legacy address references remain in on-chain state. +Run **after** all migrations complete. Queries every migrated module via RPC to confirm that no legacy address references remain in on-chain state. `x/claim` is intentionally skipped because claim records are historical records and are not re-keyed by evmigration. For each migrated legacy address, the tool checks: @@ -173,7 +172,6 @@ For each migrated legacy address, the tool checks: | **authz** | No grants as granter or grantee | | **feegrant** | No allowances as granter or grantee | | **action** | No actions referencing legacy as creator or supernode | -| **claim** | No unclaimed records;`dest_address` not pointing to legacy | | **supernode** | No `supernode_account` or `evidence.reporter_address` fields referencing legacy (note: `prev_supernode_accounts` entries are excluded — legacy addresses there are legitimate historical records) | | **evmigration** | Migration record must exist; estimate must return "already migrated" | @@ -251,7 +249,7 @@ The `make devnet-evm-upgrade` target runs the **complete end-to-end EVM upgrade | 6. Check estimates | `devnet-evmigrationp-estimate` (verify all accounts are `ready_to_migrate`) | | 7. Migrate validators | `devnet-evmigrationp-migrate-validator` (validator operators first) | | 8. Migrate accounts | `devnet-evmigrationp-migrate` (regular accounts second) | -| 9. Verify clean state | `devnet-evmigrationp-verify` (confirms no legacy address leftovers in any module) | +| 9. Verify clean state | `devnet-evmigrationp-verify` (confirms no legacy address leftovers in migrated modules; skips `x/claim`) | Each stage has error handling — if any stage fails, the pipeline aborts with a clear error message identifying which stage failed. Validators are migrated before regular accounts because `MsgMigrateValidator` atomically re-keys the validator record and all its delegations, which must happen before delegators attempt their own migration. @@ -362,7 +360,7 @@ Migrates the validator operator account on each node with full post-migration va make devnet-evmigration-verify ``` -Queries all modules via RPC to confirm no legacy address references remain (except legitimate `prev_supernode_accounts` entries). Exits non-zero if any leftover state is found. +Queries migrated modules via RPC to confirm no legacy address references remain (except legitimate `prev_supernode_accounts` entries and historical `x/claim` records). Exits non-zero if any leftover state is found. ### Step 8: Clean up From e2b75c235a98dec02041543402d68fa034771bf3 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 13:00:29 -0400 Subject: [PATCH 16/28] fix(evmigration): allow Unbonded (non-jailed) validators to migrate A validator that fell out of the active set purely on stake weight (Unbonded, not jailed) was permanently deadlocked: MsgMigrateValidator rejected both Unbonding and Unbonded, while MsgClaimLegacyAccount rejects any validator operator. Such an operator could recover neither legacy keys, funds, nor rewards, and had no economic path back across the active-set threshold. Narrow the guard to reject only Unbonding, whose live entry in the SDK unbonding-validator queue (keyed by completion time -> operator address) would be orphaned by Step V8's DeleteValidatorRecordNoHooks and halt the chain at maturity, since the module's StakingKeeper interface has no validator-queue methods. Unbonded is safe and is the recovery path: that queue entry was already dequeued (the transition that makes it Unbonded), so nothing is orphaned. MigrationEstimate now reports would_succeed=true for Unbonded non-jailed. Jailed stays blocked (checked first; recovery is unjail). Also in this change: - Remove MigrateClaim: claim records are no longer re-keyed during migration. The claim DB is frozen (claiming ended 2025-01-01) and kept for reference only; re-keying DestAddress was cosmetic (funds already moved) and scanning the ~18K-record store per migration was unbounded gas with no benefit. Drops the ClaimKeeper interface, keeper field, and depinject wiring. - Docs: record Bug #27 (delegation stake stored as tokens, not shares) and the 2026-07-03 testnet gas calibration; update the migration guides and the evmigration unit/integration test-index docs. Co-Authored-By: Claude Opus 4.7 --- ...26-06-22-validator-migration-gas-design.md | 42 ++- .../architecture/coin-type-change.md | 1 - .../evmigration/legacy-migration.md | 3 +- docs/evm-integration/evmigration/main.md | 2 +- docs/evm-integration/testing/bugs.md | 14 + .../testing/tests/integration-evmigration.md | 2 + .../testing/tests/unit-evmigration.md | 11 +- .../user-guides/validator-migration.md | 53 ++-- .../integration/evmigration/migration_test.go | 107 +++++++ x/evmigration/keeper/keeper.go | 3 - x/evmigration/keeper/keeper_test.go | 1 - x/evmigration/keeper/migrate_claim.go | 39 --- x/evmigration/keeper/migrate_test.go | 53 ---- .../keeper/msg_server_claim_legacy.go | 5 - .../keeper/msg_server_claim_legacy_test.go | 53 +--- .../keeper/msg_server_migrate_validator.go | 24 +- .../msg_server_migrate_validator_test.go | 12 +- x/evmigration/keeper/query.go | 13 +- x/evmigration/keeper/query_test.go | 100 +++++++ x/evmigration/mocks/expected_keepers_mock.go | 269 +++++++----------- x/evmigration/module/depinject.go | 3 - x/evmigration/types/errors.go | 2 +- x/evmigration/types/expected_keepers.go | 8 - 23 files changed, 424 insertions(+), 396 deletions(-) delete mode 100644 x/evmigration/keeper/migrate_claim.go diff --git a/docs/design/2026-06-22-validator-migration-gas-design.md b/docs/design/2026-06-22-validator-migration-gas-design.md index 318b5b87..b0277a78 100644 --- a/docs/design/2026-06-22-validator-migration-gas-design.md +++ b/docs/design/2026-06-22-validator-migration-gas-design.md @@ -1,7 +1,11 @@ # Validator Migration Gas: Auto-Sizing + Formula **Date:** 2026-06-22 -**Status:** Design (pending implementation) +**Status:** Implemented. The design body below (2026-06-22) is pre-implementation +analysis; its gas constants (200k base / 7k per record) and its block-gas premise +(mainnet 25M; the "~17.5M / ~11M fits in a block" reasoning) are **superseded** by +the live-measured corrections at the end — 2026-06-23 (devnet) and 2026-07-03 +(testnet). Read those before relying on any number above them. ## Problem @@ -174,3 +178,39 @@ Extrapolating linearly to mainnet's largest validator (~1597 delegations): **Operator guidance:** raise `timeout_broadcast_tx_commit` (e.g. to `600s`) on the node you broadcast through so `--gas auto` can complete, **or** broadcast with a high fixed `--gas` (skips the simulate, no timeout risk). + +--- + +## Testnet calibration (2026-07-03, live `lumera-testnet-2`) + +Live testnet data (height ~5.56M) confirms the recalibrated formula and closes the +"calibrate on a realistic-size migration" open item above. + +**Param:** `max_validator_delegations = 7500` on testnet (default is 2500; devnet 20000). +The cap bounds the **sum** of delegations + unbondings + redelegations. + +**Largest validator by records:** Nansen — `total_touched = 5752` +(`val_delegation_count = 5751` + 1 operator self-delegation), `would_succeed = true`. +Top ~10 validators cluster at 5599–5751 records. Unlike the devnet gen-activity load +(val1 was only ~43% delegations; the rest unbondings/redelegations), testnet validators +are **delegation-dominated** — `total_touched ≈ delegation count` — so delegation count +is a reliable proxy for total records here (it would not be on a chain with heavy +unbond/redelegate churn). + +**Per-record range (devnet campaign, all 5 validators):** 331k–688k/record single-sig, +**1.02M/record for the 2-of-3 multisig** (val2 — the measured ceiling). The conservative +`MIGRATION_GAS_PER_RECORD = 1,500,000` sits above that ceiling with margin. + +**Testnet gas** (`gas = 6,000,000 + per_record × records`): + +| scenario | records | @688k (single-sig) | @1.02M (multisig) | @1.5M (conservative) | +| --- | --- | --- | --- | --- | +| current largest (Nansen) | 5752 | ~3.96B | ~5.87B | ~8.63B | +| protocol cap (worst permitted) | 7500 | ~5.17B | ~7.66B | ~11.26B | + +**Block-stall implication** (measured execution rate ≈ 30M gas/s): current largest +≈ 132–288s; cap-max ≈ 172–375s. This reinforces correction §3 — the binding constraint +is execution time, not gas. A finite block `max_gas` that still admits a within-cap +migration would need to be ≥ ~11.3B (≈ 450× the 25M code default), so setting one +provides no meaningful DoS bound; the `max_validator_delegations` cap remains the real +guard, and raising it only lengthens the worst-case stall. diff --git a/docs/evm-integration/architecture/coin-type-change.md b/docs/evm-integration/architecture/coin-type-change.md index 0a9539b1..d0942287 100644 --- a/docs/evm-integration/architecture/coin-type-change.md +++ b/docs/evm-integration/architecture/coin-type-change.md @@ -73,7 +73,6 @@ Lumera chose **Approach 2A (claim-and-move)** -- the chain performs a one-time a | `x/feegrant` | Fee allowance re-keying (both granter and grantee) | | `x/supernode` | SupernodeAccount, Evidence, PrevSupernodeAccounts, MetricsState | | `x/action` | Creator and SuperNodes fields in action records | -| `x/claim` | DestAddress in claim records | ### Key design properties diff --git a/docs/evm-integration/evmigration/legacy-migration.md b/docs/evm-integration/evmigration/legacy-migration.md index f1fc543d..4318a6a6 100644 --- a/docs/evm-integration/evmigration/legacy-migration.md +++ b/docs/evm-integration/evmigration/legacy-migration.md @@ -64,8 +64,7 @@ x/evmigration/ 8. Re-key feegrant allowances 9. Update supernode account field 10. Update action creator/supernode references -11. Update claim destAddress -12. Store MigrationRecord, increment counters, emit event +11. Store MigrationRecord, increment counters, emit event ### Queries diff --git a/docs/evm-integration/evmigration/main.md b/docs/evm-integration/evmigration/main.md index 6068d136..770043c9 100644 --- a/docs/evm-integration/evmigration/main.md +++ b/docs/evm-integration/evmigration/main.md @@ -27,7 +27,6 @@ When Lumera upgrades to EVM support (v1.20.0), the same mnemonic produces a **di | `x/feegrant` | Fee allowance re-keying (both granter and grantee) | | `x/supernode` | SupernodeAccount, Evidence, PrevSupernodeAccounts, MetricsState | | `x/action` | Creator and SuperNodes fields in action records | -| `x/claim` | DestAddress in claim records | | `x/evmigration` | Core migration logic, dual-signature verification, rate limiting | ### Key design decisions @@ -36,6 +35,7 @@ When Lumera upgrades to EVM support (v1.20.0), the same mnemonic produces a **di - **Zero-fee migration** -- a custom ante decorator waives gas fees for migration txs since the new address has no balance before migration completes - **Rate limiting** -- `max_migrations_per_block` (default 50) prevents migration flood attacks - **Validator migration** -- dedicated `MsgMigrateValidator` handles the additional complexity of re-keying validator records, delegator references, and consensus key mappings +- **Claim records are not re-keyed** -- `x/claim` records are left untouched during migration. The claim DB is frozen (claiming ended 2025-01-01) and retained for reference only; re-keying `DestAddress` was cosmetic (funds already moved during the claim period). Scanning the ~18K-record claim store per migration was unbounded gas with no functional benefit, so it was removed. The legacy-to-new mapping lives in `MigrationRecords` and can reconstruct claim linkage offline if ever needed. See [legacy-migration.md](legacy-migration.md) for the full module reference. diff --git a/docs/evm-integration/testing/bugs.md b/docs/evm-integration/testing/bugs.md index 978753a2..7a7f3667 100644 --- a/docs/evm-integration/testing/bugs.md +++ b/docs/evm-integration/testing/bugs.md @@ -384,3 +384,17 @@ Meanwhile, the EVM keeper (initialized in `x/vm/keeper/keeper.go:119`) correctly **Hardening — zero-fee mempool spam gate** (`x/evmigration/keeper/ante.go`): Admitting zero-signer migration txs to the mempool also opens a zero-fee spam vector — migration txs carry no fee and no signature, so anyone could flood the mempool/proposals with proof-valid txs that only fail at message execution. `VerifyMigrationProofsForAnte` now enforces the migration admission window at the ante (rejects with `ErrMigrationDisabled` / `ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed), mirroring `preChecks` steps 1–2. It also performs cheap state admission checks before mempool insertion: the legacy account must exist and must not be a module account, the source and destination must not already be migrated/claimed, and `MsgMigrateValidator` must name an existing source validator. The window check is a no-op under default params (`EnableMigration=true`, `MigrationEndTime=0`); on mainnet the operator-set `MigrationEndTime` bounds the exposure to the migration window and closes it automatically. Message execution still re-checks the full canonical migration rules. **Tests**: `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx`, `TestEVMMempool_CheckTxRejectsProofValidNonexistentLegacyAccount`, `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx`, `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress`, `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx`, `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx`, `TestVerifyMigrationProofsForAnte_AdmissionGate` (disabled / window-closed / open-window), `TestVerifyMigrationProofsForAnte_CheapStateAdmission`, and real-node `broadcast_tx_sync` coverage in `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled`, `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte`, `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic`, and `TestZeroSignerNonMigrationBroadcastSyncStillRejected`. + +--- + +### 27) Migration rebuilds `DelegatorStartingInfo` with share count instead of token stake — bricks withdrawals after any past slash + +**Severity**: High + +**Symptom**: After migrating an account or validator that delegates to (or is) a validator that was ever slashed, the delegator's next `WithdrawDelegatorReward`, undelegate, or redelegate panics in the SDK with `calculated final stake for delegation ... greater than current stake`. The migration itself succeeds, so the fund-locking damage only surfaces on the delegator's *next* reward/unbond operation and affects every delegation re-keyed to a slashed validator. + +**Root cause**: Both migration paths rebuilt `DelegatorStartingInfo.Stake` from the delegation's raw `Shares`. The SDK's own `initializeDelegation` stores stake as `validator.TokensFromSharesTruncated(shares)` (tokens), not shares. Shares and tokens are equal only while a validator's exchange rate is exactly 1.0; slashing permanently lowers `validator.Tokens` while leaving `DelegatorShares` unchanged, so the rate stays below 1.0 forever. Storing shares therefore overstates the recorded stake. `CalculateDelegationRewards` later reads `stake := startingInfo.Stake`, recomputes `currentStake := val.TokensFromShares(del.GetShares())`, and panics when `stake` exceeds `currentStake` beyond a tiny rounding margin. The pre-checks only reject *currently* jailed validators, so a slashed-then-unjailed validator passes migration and hits this panic. + +**Fix** (`x/evmigration/keeper/migrate_validator.go`, `x/evmigration/keeper/migrate_staking.go`): Both paths now fetch the target validator and store `val.TokensFromSharesTruncated(del.Shares)`, matching the SDK invariant. In `MigrateValidatorDelegations` the fetch is hoisted out of the delegation loop (all delegations re-key to the same validator, so one `GetValidator(newValAddr)` suffices). In `migrateActiveDelegations` the fetch is per-delegation because each delegation targets a different third-party validator. + +**Tests**: `TestMigrateValidatorDelegations_SlashedValidatorStoresTokensNotShares`, `TestMigrateStaking_SlashedValidatorStoresTokensNotShares` — pin `DelegatorStartingInfo.Stake` to the tokens-from-shares value for a slashed validator (90 tokens / 100 shares) and assert it is strictly less than `del.Shares`. diff --git a/docs/evm-integration/testing/tests/integration-evmigration.md b/docs/evm-integration/testing/tests/integration-evmigration.md index 278cffd6..aa325d0f 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -17,10 +17,12 @@ Additional real-node broadcast coverage for zero-signer `submit-proof` txs lives | `TestClaimLegacyAccount_ValidatorMustUseMigrateValidator` | Validator operators rejected from ClaimLegacyAccount with real staking state. | | `TestClaimLegacyAccount_MultiDenom` | Multi-denomination balance transfer verified with real bank module. | | `TestClaimLegacyAccount_LegacyAccountRemoved` | Legacy auth account removed and new account exists after migration. | +| `TestClaimLegacyAccount_LeavesClaimRecordUntouched` | Migration does not re-key claim records: a seeded claim record whose DestAddress points at the legacy address is left unchanged (claim DB is reference-only). | | `TestClaimLegacyAccount_AfterValidatorMigration` | Fresh-state validator-first flow: migrate validator first, then migrate a legacy delegator account. | | `TestMigrateValidator_Success` | End-to-end validator migration: bonded validator with self-delegation + external delegator. | | `TestMigrateValidator_NotValidator` | Rejection when legacy address is not a validator operator with real staking state. | | `TestMigrateValidator_JailedValidator` | Rejection when validator is jailed with real staking/auth state; asserts no migration record or destination validator is created. | +| `TestMigrateValidator_UnbondedNotJailedSucceeds` | Recovery path: an Unbonded, non-jailed validator (fell out of the active set on stake weight) migrates successfully with full re-keying of the validator record, delegations, distribution, and counters. | | `TestQueryMigrationRecord_Integration` | Query server returns record after real migration, nil before. | | `TestQueryMigrationEstimate_Integration` | Estimate query with real staking state reports correct values. | | `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Mempool-suite regression: valid zero-signer migration tx passes real-node CheckTx with app-side mempool enabled. | diff --git a/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index 21c952a8..622f6199 100644 --- a/docs/evm-integration/testing/tests/unit-evmigration.md +++ b/docs/evm-integration/testing/tests/unit-evmigration.md @@ -43,8 +43,6 @@ Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/ | `TestMigrateSupernode_NotFound` | Verifies no-op when legacy is not a supernode. | | `TestMigrateActions_CreatorAndSuperNodes` | Verifies Creator and SuperNodes fields are updated. | | `TestMigrateActions_NoMatch` | Verifies no-op when no actions reference legacy address. | -| `TestMigrateClaim_Found` | Verifies claim record DestAddress is updated. | -| `TestMigrateClaim_NotFound` | Verifies no-op when there is no claim record. | | `TestMigrateStaking_ActiveDelegations` | Verifies full staking migration: delegation re-keying, starting info, withdraw addr. | | `TestMigrateStaking_NoDelegations` | Verifies no-op when delegator has no delegations. | | `TestMigrateStaking_ThirdPartyWithdrawAddress` | Verifies third-party withdraw address is preserved via origWithdrawAddr parameter (bug #16). | @@ -67,12 +65,11 @@ Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/ | `TestClaimLegacyAccount_FailAtAuthz` | Failure at step 4 (authz grant re-keying) propagates error. | | `TestClaimLegacyAccount_FailAtFeegrant` | Failure at step 5 (feegrant migration) propagates error. | | `TestClaimLegacyAccount_FailAtSupernode` | Failure at step 6 (supernode migration) propagates error. | -| `TestClaimLegacyAccount_FailAtActions` | Failure at step 7 (action migration) propagates error. | -| `TestClaimLegacyAccount_FailAtClaim` | Failure at step 8 (claim migration, last before finalize) propagates error. | +| `TestClaimLegacyAccount_FailAtActions` | Failure at step 7 (action migration, last before finalize) propagates error. | | `TestClaimLegacyAccount_WithDelegations` | Verifies rewards withdrawal and delegation re-keying during claim. | | `TestClaimLegacyAccount_MigratedThirdPartyWithdrawAddress` | End-to-end message-server test: third-party withdraw addr resolved to migrated destination (bug #16). | | `TestMigrateValidator_NotValidator` | Verifies rejection when legacy address is not a validator operator. | -| `TestMigrateValidator_UnbondingValidator` | Verifies rejection when validator is unbonding or unbonded. | +| `TestMigrateValidator_UnbondingValidator` | Verifies rejection when validator is still unbonding (Unbonded is now migratable). | | `TestMigrateValidator_JailedValidator` | Verifies jailed validators are rejected before any validator migration mutation path. | | `TestMigrateValidator_TooManyDelegators` | Verifies rejection when delegation records exceed MaxValidatorDelegations. | | `TestMigrateValidator_Success` | Verifies full validator migration: commission, record, delegations, distribution, supernode, account. | @@ -83,6 +80,8 @@ Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/ | `TestQueryMigrationStats` | Verifies counters and computed stats are returned. | | `TestQueryMigrationEstimate_NonValidator` | Verifies estimate for non-validator address with delegations. | | `TestQueryMigrationEstimate_AlreadyMigrated` | Verifies already-migrated addresses report would_succeed=false. | +| `TestMigrationEstimate_ValidatorUnbondedNotJailed_WouldSucceed` | Verifies an Unbonded, non-jailed validator reports would_succeed=true (recovery path). | +| `TestMigrationEstimate_ValidatorUnbonding_WouldFail` | Verifies an Unbonding validator reports would_succeed=false with a wait-for-unbonding-period reason. | | `TestQueryLegacyAccounts_WithSecp256k1` | Verifies accounts with secp256k1 pubkeys are listed as legacy. | | `TestQueryLegacyAccounts_Pagination` | Multi-page offset pagination: page 1 has NextKey, page 2 returns remainder without NextKey. | | `TestQueryLegacyAccounts_Empty` | Empty response when no legacy accounts exist; Total=0, no NextKey. | @@ -126,6 +125,8 @@ Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/ | `TestMigrateValidatorDelegations_RedelegationReplayIsDeterministic` | Redelegations from the scoped scan replay in deterministic store-key order, guarding against Go map-iteration nondeterminism that would diverge app hashes across nodes. | | `TestMigrateValidator_TooManyDelegatorsIncludesScopedRedelegations` | The MaxValidatorDelegations pre-check counts scoped redelegations (source and destination); exceeding the limit rejects with `ErrTooManyDelegators` even with no plain delegations/unbondings. | | `TestQueryMigrationEstimate_ValidatorUsesScopedRedelegationIndexesForLimit` | The `MigrationEstimate` query counts a validator's redelegations via scoped indexes (source and destination, excluding unrelated) when reporting the delegation count. | +| `TestMigrateValidatorDelegations_SlashedValidatorStoresTokensNotShares` | V4 stores `DelegatorStartingInfo.Stake` as tokens-from-shares (truncated) via the re-keyed validator's exchange rate, not raw shares, so an ever-slashed validator (tokens < shares) cannot trip the SDK's final-stake panic on later withdrawals. | +| `TestMigrateStaking_SlashedValidatorStoresTokensNotShares` | Account-path delegation re-keying (`migrateActiveDelegations`) applies the same shares→token stake conversion for a delegation to an ever-slashed validator. | **Additional regression coverage**: `TestKeeper_GetSuperNodeByAccount` (in `x/supernode/v1/keeper/`) confirms `GetSuperNodeByAccount` returns the correct supernode for a given account address, exercising the index used by `MigrateSupernode`. diff --git a/docs/evm-integration/user-guides/validator-migration.md b/docs/evm-integration/user-guides/validator-migration.md index 3a7599c3..154d5bff 100644 --- a/docs/evm-integration/user-guides/validator-migration.md +++ b/docs/evm-integration/user-guides/validator-migration.md @@ -49,13 +49,13 @@ The consensus key, voting power at block height, and validator jailing/slashing Check for: - `would_succeed: true` — the migration can proceed. - `is_validator: true` — the chain recognizes this address as a validator operator. - - `validator_status: "BOND_STATUS_BONDED"` and `validator_jailed: false` — the validator is in the active set and not jailed. If either fails, see [Step 3a](#step-3a--recovering-from-a-jailed-or-non-bonded-validator) before proceeding. + - `validator_jailed: false` **and** `validator_status` is not `BOND_STATUS_UNBONDING` — migration requires the validator to be un-jailed and not mid-unbonding. Both `BOND_STATUS_BONDED` and `BOND_STATUS_UNBONDED` (with `validator_jailed: false`) are migratable — a validator that fell out of the active set purely on stake weight does **not** need to re-enter it. If the validator is jailed or still `BOND_STATUS_UNBONDING`, see [Step 3a](#step-3a--recovering-from-a-jailed-or-unbonding-validator) before proceeding. - `val_delegation_count + val_unbonding_count + val_redelegation_count` at or below `max_validator_delegations` (default `2500`). If exceeded, governance must raise the limit or delegators must redelegate out before migration. - - `rejection_reason` empty. Common non-empty values: validator is jailed (recoverable via `unjail`), validator is voluntarily unbonded (recoverable by re-staking), migration is disabled by param, deadline has passed. + - `rejection_reason` empty. Common non-empty values: validator is jailed (recoverable via `unjail`), validator is unbonding (wait for the unbonding period to complete, then migrate), migration is disabled by param, deadline has passed. 3. **Prepare both keys.** You need the legacy `secp256k1` key (coin-type 118) and a new `eth_secp256k1` key (coin-type 60) derived from the **same mnemonic**. See step 2 below. 4. **Pick a trusted external RPC.** Your own node will be stopped during the migration broadcast, so route the migration tx through a trusted peer. -5. **Confirm the validator is healthy *now*.** Sample the active validator set (`lumerad query staking validators --output json | jq '.validators[] | select(.operator_address == "") | {status, jailed, tokens}'`) and confirm `BOND_STATUS_BONDED` + `jailed: false` immediately before the maintenance window. A jail event between checklist completion and migration start is the most common preventable cause of a failed migration window — keep the gap short, and re-run pre-flight just before Step 4. +5. **Confirm the validator is healthy *now*.** Sample the active validator set (`lumerad query staking validators --output json | jq '.validators[] | select(.operator_address == "") | {status, jailed, tokens}'`) and confirm `jailed: false` (and that status is not `BOND_STATUS_UNBONDING`) immediately before the maintenance window. A jail event between checklist completion and migration start is the most common preventable cause of a failed migration window — keep the gap short, and re-run pre-flight just before Step 4. --- @@ -124,22 +124,22 @@ lumerad query evmigration migration-estimate } ``` -`would_succeed: true` with `is_validator: true`, `validator_status: BOND_STATUS_BONDED`, `validator_jailed: false`, and `val_delegation_count + val_unbonding_count + val_redelegation_count <= max_validator_delegations` means you're clear to proceed. +`would_succeed: true` with `is_validator: true`, `validator_jailed: false`, `validator_status` ∈ {`BOND_STATUS_BONDED`, `BOND_STATUS_UNBONDED`}, and `val_delegation_count + val_unbonding_count + val_redelegation_count <= max_validator_delegations` means you're clear to proceed. > **A terminal rejection may return *only* `rejection_reason`.** When the account can never be migrated as-is — most commonly because it was **already migrated** — the estimate collapses to a single field, e.g. `{ "rejection_reason": "already migrated" }`, with none of the `is_validator` / `would_succeed` / count fields shown above. The query isn't broken; the condition is terminal. For `already migrated`, look up where it went with `lumerad query evmigration migration-record ` and use the new address going forward. See [Troubleshooting](#troubleshooting). -The chain checks the validator's bond status and jailed flag and rejects migration when either disqualifies the validator. Two failure shapes you may see: +The chain rejects migration in exactly two cases: the validator is **jailed**, or it is still **`BOND_STATUS_UNBONDING`**. A `BOND_STATUS_UNBONDED` validator that is *not* jailed is fully migratable — this is the recovery path for an operator who fell out of the active set on stake weight. The failure shapes you may see: -- `validator_status` is `BOND_STATUS_UNBONDING` or `BOND_STATUS_UNBONDED` with `validator_jailed: true` → the validator was jailed (typically for downtime). Recoverable: see [Step 3a](#step-3a--recovering-from-a-jailed-or-non-bonded-validator). -- `validator_status` is `BOND_STATUS_UNBONDING` or `BOND_STATUS_UNBONDED` with `validator_jailed: false` → the validator voluntarily exited the active set (self-unbonded). Less common; recoverable by re-delegating self-stake until back in the active set, then re-running pre-flight. +- `validator_jailed: true` (status is then `BOND_STATUS_UNBONDING` or `BOND_STATUS_UNBONDED`) → the validator was jailed (typically for downtime). Recoverable: see [Step 3a](#step-3a--recovering-from-a-jailed-or-unbonding-validator). +- `validator_status: BOND_STATUS_UNBONDING` with `validator_jailed: false` → the validator is mid-unbonding (voluntarily exiting, or being pushed out of the active set). Wait for the unbonding period to complete — the validator then becomes `BOND_STATUS_UNBONDED` and is directly migratable. See [Step 3a](#step-3a--recovering-from-a-jailed-or-unbonding-validator). -> **Why both fields exist.** A jailed validator is *always* `Unbonding` or `Unbonded` (jailing transitions out of the active set). But the reverse isn't true — voluntary unbonding doesn't set the jailed flag. Surfacing both lets you distinguish "needs `unjail`" from "needs `delegate`". +> **Why `BOND_STATUS_UNBONDING` is blocked but `BOND_STATUS_UNBONDED` is not.** An unbonding validator still holds a live entry in the staking module's unbonding-validator queue, keyed by its operator address. Re-keying the operator during migration would orphan that queue entry and halt the chain when it matures. An *unbonded* validator has already been dequeued, so there is nothing to orphan — migration re-keys its record safely. A jailed validator is always `Unbonding` or `Unbonded`, but the reverse isn't true; surfacing both `validator_status` and `validator_jailed` lets you distinguish "needs `unjail`", "wait for unbonding", and "clear to migrate". -## Step 3a — Recovering from a jailed or non-bonded validator +## Step 3a — Recovering from a jailed or unbonding validator Skip this section if Step 3 returned `would_succeed: true`. -If the pre-flight reported `validator_jailed: true`, your validator was kicked out of the active set for a slashable offense (almost always downtime — your node was offline long enough to miss `min_signed_per_window` × `signed_blocks_window` blocks). Migration is gated until you bring the validator back to `BOND_STATUS_BONDED` with `jailed: false`. +If the pre-flight reported `validator_jailed: true`, your validator was kicked out of the active set for a slashable offense (almost always downtime — your node was offline long enough to miss `min_signed_per_window` × `signed_blocks_window` blocks). Migration is gated until you clear the jailed flag with `unjail`. You do **not** need to return to the active set: once `jailed: false`, the validator is migratable whether it ends up `BOND_STATUS_BONDED` (enough stake to rebond) or `BOND_STATUS_UNBONDED` (not enough) — only a jailed or still-`BOND_STATUS_UNBONDING` validator is blocked. ### The timing trap @@ -164,7 +164,8 @@ lumerad tx slashing unjail \ VALOPER=$(lumerad debug addr | awk -F': ' '/^Bech32 Val: /{print $2; exit}') lumerad query staking validator "$VALOPER" --output json \ | jq '.validator | {status, jailed, tokens, delegator_shares}' -# Expect: status = BOND_STATUS_BONDED, jailed = false. +# Expect: jailed = false. Status BOND_STATUS_BONDED (rebonded) or +# BOND_STATUS_UNBONDED (not enough stake to rebond) — both are migratable. # 5. Re-run the pre-flight estimate to confirm migration is now unblocked. lumerad query evmigration migration-estimate @@ -180,18 +181,11 @@ systemctl stop lumerad - **`validator missing self-delegation`** — your validator's self-stake fell below `min_self_delegation`. Self-delegate first (`lumerad tx staking delegate `), then retry unjail. - **`unauthorized: account does not exist`** — the operator key you're signing with isn't the validator's operator. Confirm `lumerad keys show -a` matches the legacy address you're migrating. -### What if the validator is `Unbonded` with `jailed: false`? +### What if the validator is `BOND_STATUS_UNBONDED` with `jailed: false`? -This is the voluntary-exit case (no slashing event, just `MsgUndelegate`'d below the threshold). `unjail` doesn't apply — there's nothing to un-jail. Instead, re-stake until you're back in the active set: +**No recovery needed — this validator is directly migratable.** An unbonded, un-jailed validator (voluntary exit, or pushed out of the top `max_validators` slots on stake weight) has already been removed from the unbonding-validator queue, so migration can safely re-key it without orphaning any queue entry. You do **not** need to re-stake or re-enter the active set — doing so is neither required nor helpful. Skip straight to Step 4 and run the migration. -```bash -lumerad tx staking delegate ulume \ - --from --chain-id -# Wait for the next end-block; the validator will rebond if its -# new stake puts it in the top max_validators slots. -``` - -Then re-run pre-flight as in step 5 above, and proceed. +If instead the validator is still `BOND_STATUS_UNBONDING` (not yet `UNBONDED`), it still holds a live queue entry; wait for the unbonding period to complete so it transitions to `BOND_STATUS_UNBONDED`, then migrate. ## Step 4 — Stop the validator @@ -396,7 +390,7 @@ lumerad query supernode get-supernode ### `would_succeed: false`, `rejection_reason: validator is jailed (status: ...)` -Your validator was kicked out of the active set for a slashable offense (almost always downtime). The pre-flight response will also show `validator_jailed: true` and `validator_status` ∈ {`BOND_STATUS_UNBONDING`, `BOND_STATUS_UNBONDED`}. The full recovery flow — restart node → wait for catch-up → `unjail` → confirm `BOND_STATUS_BONDED` → stop node → retry migration — is documented in [Step 3a](#step-3a--recovering-from-a-jailed-or-non-bonded-validator). +Your validator was kicked out of the active set for a slashable offense (almost always downtime). The pre-flight response will also show `validator_jailed: true` and `validator_status` ∈ {`BOND_STATUS_UNBONDING`, `BOND_STATUS_UNBONDED`}. The full recovery flow — restart node → wait for catch-up → `unjail` → confirm `jailed: false` → stop node → retry migration — is documented in [Step 3a](#step-3a--recovering-from-a-jailed-or-unbonding-validator). After `unjail` the validator is migratable whether it rebonds to `BOND_STATUS_BONDED` or stays `BOND_STATUS_UNBONDED`. The minimum command, assuming the node is up and synced: @@ -409,18 +403,13 @@ lumerad tx slashing unjail \ If unjail itself fails with `validator still jailed; cannot be unjailed`, the slashing window hasn't fully elapsed. Wait, then retry. -### `would_succeed: false`, `rejection_reason: validator status is unbonded (not bonded)` (no jail) +### `would_succeed: false`, `rejection_reason: validator is unbonding; wait for the unbonding period to complete, then migrate` -The pre-flight response shows `validator_jailed: false` and `validator_status: BOND_STATUS_UNBONDING` (or `UNBONDED`). This is the voluntary-exit case: the validator self-unbonded (or fell out of the top `max_validators` slots) without ever being jailed, so `unjail` does nothing. Re-stake to re-enter the active set: - -```bash -lumerad tx staking delegate ulume \ - --from --chain-id -``` +The pre-flight response shows `validator_jailed: false` and `validator_status: BOND_STATUS_UNBONDING`. The validator is mid-unbonding (voluntary exit, or pushed out of the top `max_validators` slots) and still holds a live unbonding-validator-queue entry keyed by the old operator address. Migrating now would orphan that entry and halt the chain at maturity, so the chain blocks it. **Do nothing but wait:** once the unbonding period elapses the validator transitions to `BOND_STATUS_UNBONDED`, at which point it is directly migratable — no re-staking required. Re-run pre-flight after the transition and proceed. See [Step 3a](#step-3a--recovering-from-a-jailed-or-unbonding-validator) for the longer treatment. -Then wait for the next end-block, re-run pre-flight, and proceed once `validator_status` is `BOND_STATUS_BONDED`. See [Step 3a — voluntary-exit case](#what-if-the-validator-is-unbonded-with-jailed-false) for the longer treatment. +> **A `BOND_STATUS_UNBONDED` (not jailed) validator is *not* rejected.** Only `BOND_STATUS_UNBONDING` and jailed validators are blocked. If your validator fell out of the active set on stake weight and has finished unbonding, migration succeeds directly — you do not need to re-enter the active set. -> **Older versions of this doc / chain referenced `rejection_reason: validator is not in bonded status`.** The current chain produces the more specific messages above. If you see the old text, you're talking to a node running pre-jailed-field code; the underlying condition is the same. +> **Older versions of this doc / chain referenced `rejection_reason: validator is not in bonded status` or `validator is unbonding or unbonded; wait for completion`, and rejected `BOND_STATUS_UNBONDED` too.** The current chain migrates an unbonded, un-jailed validator directly and only blocks `BOND_STATUS_UNBONDING`. If you see the old text or an unbonded validator being rejected, you're talking to a node running older code. ### `would_succeed: false`, `rejection_reason: validator exceeds max_validator_delegations` @@ -587,7 +576,7 @@ No. `priv_validator_key.json` (ed25519) is untouched. Only the operator key (`se No — `MsgMigrateValidator` is purely a re-keying operation. Use `MsgEditValidator` before or after migration for any description/commission changes. **Q: My validator is in the active set but my migration estimate still says `would_succeed: false`. Why?** -Check `rejection_reason` in the estimate response. The most common causes are validator status (must be `Bonded`, not `Unbonding`/`Unbonded`), exceeded `max_validator_delegations`, or migration being globally disabled via the `enable_migration` param. +Check `rejection_reason` in the estimate response. The most common causes are the validator being jailed (run `unjail`) or still `Unbonding` (wait for the unbonding period to complete), exceeded `max_validator_delegations`, or migration being globally disabled via the `enable_migration` param. Note that a `BOND_STATUS_UNBONDED` validator that is *not* jailed **is** migratable — being out of the active set on stake weight alone does not block migration. **Q: I also run a supernode on this validator. What order do I migrate in?** Migrate the validator first; `MsgMigrateValidator` handles the supernode side as a side-effect. Then restart both `lumerad` and `supernode`. See [supernode-migration.md](supernode-migration.md) for the daemon's self-healing on startup. diff --git a/tests/integration/evmigration/migration_test.go b/tests/integration/evmigration/migration_test.go index 2cf0a242..d73b69ed 100644 --- a/tests/integration/evmigration/migration_test.go +++ b/tests/integration/evmigration/migration_test.go @@ -28,6 +28,7 @@ import ( "github.com/LumeraProtocol/lumera/app" lcfg "github.com/LumeraProtocol/lumera/config" + claimtypes "github.com/LumeraProtocol/lumera/x/claim/types" evmigrationkeeper "github.com/LumeraProtocol/lumera/x/evmigration/keeper" "github.com/LumeraProtocol/lumera/x/evmigration/types" ) @@ -212,6 +213,39 @@ func (s *MigrationIntegrationSuite) TestClaimLegacyAccount_Success() { s.Require().Equal(uint64(1), count, "migration counter should be exactly 1") } +// TestClaimLegacyAccount_LeavesClaimRecordUntouched verifies migration does NOT +// rewrite claim records whose DestAddress points at the legacy address. The +// claim DB is frozen (claiming ended 2025-01-01) and retained for reference +// only; the legacy->new mapping lives in MigrationRecords, so the per-migration +// full scan of the claim store (unscopable — no DestAddress index) was removed +// from the hot path. +func (s *MigrationIntegrationSuite) TestClaimLegacyAccount_LeavesClaimRecordUntouched() { + s.enableMigration() + + coins := sdk.NewCoins(sdk.NewInt64Coin("ulume", 1_000_000)) + privKey, legacyAddr := s.createFundedLegacyAccount(coins) + newPrivKey, newAddr := createNewEVMAddress(s.T()) + + // Seed a claim record whose DestAddress points at the migrating legacy addr. + const oldAddress = "pastel1legacyoldaddress" + s.Require().NoError(s.app.ClaimKeeper.SetClaimRecord(s.ctx, claimtypes.ClaimRecord{ + OldAddress: oldAddress, + DestAddress: legacyAddr.String(), + })) + + msg := newClaimMsg(s.T(), privKey, legacyAddr, newPrivKey, newAddr) + _, err := s.msgServer.ClaimLegacyAccount(s.ctx, msg) + s.Require().NoError(err) + + // The claim record must be left exactly as-is: still present, DestAddress + // unchanged. Migration must not touch the claim store. + got, found, err := s.app.ClaimKeeper.GetClaimRecord(s.ctx, oldAddress) + s.Require().NoError(err) + s.Require().True(found, "claim record should still exist after migration") + s.Require().Equal(legacyAddr.String(), got.DestAddress, + "migration must not rewrite claim record DestAddress") +} + // TestClaimLegacyAccount_MigratesAndRevokesFeegrants verifies that feegrant // allowances are re-keyed to the migrated address and the legacy entries are // removed from the concrete SDK feegrant keeper. @@ -845,6 +879,79 @@ func (s *MigrationIntegrationSuite) TestMigrateValidator_JailedValidator() { s.Require().Error(err, "destination validator must not be created") } +// TestMigrateValidator_UnbondedNotJailedSucceeds verifies the recovery path for a +// validator that fell out of the active set purely on stake weight (Unbonded, NOT +// jailed). Such a validator has no entry in the unbonding-validator queue, so +// re-keying and deleting the old record cannot orphan a queue entry (the halt risk +// that keeps Unbonding blocked). Migration must therefore succeed, unblocking the +// operator to recover keys, funds, and rewards without re-entering the active set. +func (s *MigrationIntegrationSuite) TestMigrateValidator_UnbondedNotJailedSucceeds() { + s.enableMigration() + + selfBondAmt := sdkmath.NewInt(1_000_000) + operatorCoins := sdk.NewCoins(sdk.NewInt64Coin("ulume", 2_000_000)) + legacyPrivKey, legacyAddr := s.createFundedLegacyAccount(operatorCoins) + oldValAddr, extDelegatorAddr := s.createTestValidator(legacyAddr, selfBondAmt) + + // Simulate dropping out of the active set on stake weight: Unbonded, NOT + // jailed, and removed from the bonded power set (no last-validator-power entry). + val, err := s.app.StakingKeeper.GetValidator(s.ctx, oldValAddr) + s.Require().NoError(err) + s.Require().False(val.Jailed, "precondition: validator is not jailed") + s.Require().NoError(s.app.StakingKeeper.DeleteValidatorByPowerIndex(s.ctx, val)) + s.Require().NoError(s.app.StakingKeeper.DeleteLastValidatorPower(s.ctx, oldValAddr)) + val.Status = stakingtypes.Unbonded + s.Require().NoError(s.app.StakingKeeper.SetValidator(s.ctx, val)) + + newPrivKey, newAddr := createNewEVMAddress(s.T()) + newValAddr := sdk.ValAddress(newAddr) + + msg := newValidatorMsg(s.T(), legacyPrivKey, legacyAddr, newPrivKey, newAddr) + resp, err := s.msgServer.MigrateValidator(s.ctx, msg) + s.Require().NoError(err, "unbonded (non-jailed) validator must be migratable") + s.Require().NotNil(resp) + + // Validator record re-keyed to the new operator, still Unbonded. + newVal, err := s.app.StakingKeeper.GetValidator(s.ctx, newValAddr) + s.Require().NoError(err, "new validator should exist") + s.Require().Equal(newValAddr.String(), newVal.OperatorAddress) + s.Require().Equal(stakingtypes.Unbonded, newVal.Status) + + // Old validator record is deleted (V8), leaving no orphan. + _, err = s.app.StakingKeeper.GetValidator(s.ctx, oldValAddr) + s.Require().Error(err, "old validator record should be deleted") + + // Delegations re-keyed to the new validator; none left on the old. + dels, err := s.app.StakingKeeper.GetValidatorDelegations(s.ctx, newValAddr) + s.Require().NoError(err) + s.Require().Len(dels, 2, "self-delegation + external delegation re-keyed") + oldDels, err := s.app.StakingKeeper.GetValidatorDelegations(s.ctx, oldValAddr) + s.Require().NoError(err) + s.Require().Empty(oldDels) + + // Distribution state re-keyed. + _, err = s.app.DistrKeeper.GetValidatorCurrentRewards(s.ctx, newValAddr) + s.Require().NoError(err, "current rewards should exist for new validator") + + // Bank balances moved. + s.Require().True(s.app.BankKeeper.GetAllBalances(s.ctx, legacyAddr).IsZero()) + s.Require().True(s.app.BankKeeper.GetAllBalances(s.ctx, newAddr).AmountOf("ulume").GT(sdkmath.ZeroInt())) + + // Migration record + validator counter. + record, err := s.keeper.MigrationRecords.Get(s.ctx, legacyAddr.String()) + s.Require().NoError(err) + s.Require().Equal(newAddr.String(), record.NewAddress) + valCount, err := s.keeper.ValidatorMigrationCounter.Get(s.ctx) + s.Require().NoError(err) + s.Require().Equal(uint64(1), valCount) + + // External delegator's delegation now points to the new validator. + extDels, err := s.app.StakingKeeper.GetDelegatorDelegations(s.ctx, extDelegatorAddr, 10) + s.Require().NoError(err) + s.Require().Len(extDels, 1) + s.Require().Equal(newValAddr.String(), extDels[0].ValidatorAddress) +} + // TestClaimLegacyAccount_LegacyAccountRemoved verifies that the legacy auth // account is removed after migration and the new account exists. func (s *MigrationIntegrationSuite) TestClaimLegacyAccount_LegacyAccountRemoved() { diff --git a/x/evmigration/keeper/keeper.go b/x/evmigration/keeper/keeper.go index 5040e369..a787f503 100644 --- a/x/evmigration/keeper/keeper.go +++ b/x/evmigration/keeper/keeper.go @@ -69,7 +69,6 @@ type Keeper struct { feegrantKeeper types.FeegrantKeeper supernodeKeeper types.SupernodeKeeper actionKeeper types.ActionKeeper - claimKeeper types.ClaimKeeper } func NewKeeper( @@ -85,7 +84,6 @@ func NewKeeper( feegrantKeeper types.FeegrantKeeper, supernodeKeeper types.SupernodeKeeper, actionKeeper types.ActionKeeper, - claimKeeper types.ClaimKeeper, ) Keeper { if _, err := addressCodec.BytesToString(authority); err != nil { panic(fmt.Sprintf("invalid authority address %s: %s", authority, err)) @@ -114,7 +112,6 @@ func NewKeeper( feegrantKeeper: feegrantKeeper, supernodeKeeper: supernodeKeeper, actionKeeper: actionKeeper, - claimKeeper: claimKeeper, // Allocate once so value-copies of Keeper (e.g. app.EvmigrationKeeper // and AppModule.keeper) share the same mutable handle. app.go writes diff --git a/x/evmigration/keeper/keeper_test.go b/x/evmigration/keeper/keeper_test.go index db0bf1cb..ac20d4fd 100644 --- a/x/evmigration/keeper/keeper_test.go +++ b/x/evmigration/keeper/keeper_test.go @@ -49,7 +49,6 @@ func initFixture(t *testing.T) *fixture { nil, // feegrantKeeper nil, // supernodeKeeper nil, // actionKeeper - nil, // claimKeeper ) // Initialize params and counters diff --git a/x/evmigration/keeper/migrate_claim.go b/x/evmigration/keeper/migrate_claim.go deleted file mode 100644 index 0167d85b..00000000 --- a/x/evmigration/keeper/migrate_claim.go +++ /dev/null @@ -1,39 +0,0 @@ -package keeper - -import ( - sdk "github.com/cosmos/cosmos-sdk/types" - - claimtypes "github.com/LumeraProtocol/lumera/x/claim/types" -) - -// MigrateClaim updates claim records where DestAddress matches legacyAddr. -// This is cosmetic/audit — claim funds were already transferred during the -// claim period. Updates the record to point to the new address. -func (k Keeper) MigrateClaim(ctx sdk.Context, legacyAddr, newAddr sdk.AccAddress) error { - var matchingOldAddresses []string - err := k.claimKeeper.IterateClaimRecords(ctx, func(record claimtypes.ClaimRecord) (bool, error) { - if record.DestAddress == legacyAddr.String() { - matchingOldAddresses = append(matchingOldAddresses, record.OldAddress) - } - return false, nil - }) - if err != nil { - return err - } - - for _, oldAddress := range matchingOldAddresses { - record, found, err := k.claimKeeper.GetClaimRecord(ctx, oldAddress) - if err != nil { - return err - } - if !found || record.DestAddress != legacyAddr.String() { - continue - } - record.DestAddress = newAddr.String() - if err := k.claimKeeper.SetClaimRecord(ctx, record); err != nil { - return err - } - } - - return nil -} diff --git a/x/evmigration/keeper/migrate_test.go b/x/evmigration/keeper/migrate_test.go index 210a1a96..6216b1d7 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -29,7 +29,6 @@ import ( "go.uber.org/mock/gomock" actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" - claimtypes "github.com/LumeraProtocol/lumera/x/claim/types" "github.com/LumeraProtocol/lumera/x/evmigration/keeper" evmigrationmocks "github.com/LumeraProtocol/lumera/x/evmigration/mocks" module "github.com/LumeraProtocol/lumera/x/evmigration/module" @@ -52,7 +51,6 @@ type mockFixture struct { feegrantKeeper *evmigrationmocks.MockFeegrantKeeper supernodeKeeper *evmigrationmocks.MockSupernodeKeeper actionKeeper *evmigrationmocks.MockActionKeeper - claimKeeper *evmigrationmocks.MockClaimKeeper } func initMockFixture(t *testing.T) *mockFixture { @@ -68,7 +66,6 @@ func initMockFixture(t *testing.T) *mockFixture { feegrantKeeper := evmigrationmocks.NewMockFeegrantKeeper(ctrl) supernodeKeeper := evmigrationmocks.NewMockSupernodeKeeper(ctrl) actionKeeper := evmigrationmocks.NewMockActionKeeper(ctrl) - claimKeeper := evmigrationmocks.NewMockClaimKeeper(ctrl) encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModule{}) addrCodec := addresscodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) @@ -103,7 +100,6 @@ func initMockFixture(t *testing.T) *mockFixture { feegrantKeeper, supernodeKeeper, actionKeeper, - claimKeeper, ) // Initialize params with migration enabled. @@ -126,7 +122,6 @@ func initMockFixture(t *testing.T) *mockFixture { feegrantKeeper: feegrantKeeper, supernodeKeeper: supernodeKeeper, actionKeeper: actionKeeper, - claimKeeper: claimKeeper, } } @@ -1242,54 +1237,6 @@ func TestMigrateActions_NoMatch(t *testing.T) { require.NoError(t, err) } -// --- MigrateClaim tests --- - -// TestMigrateClaim_Found verifies that the claim record's DestAddress is updated. -func TestMigrateClaim_Found(t *testing.T) { - f := initMockFixture(t) - legacy := testAccAddr() - newAddr := testAccAddr() - - record := claimtypes.ClaimRecord{ - OldAddress: "pastel1legacyoldaddress", - DestAddress: legacy.String(), - } - - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, cb func(claimtypes.ClaimRecord) (bool, error)) error { - _, err := cb(record) - return err - }) - f.claimKeeper.EXPECT().GetClaimRecord(gomock.Any(), record.OldAddress).Return(record, true, nil) - f.claimKeeper.EXPECT().SetClaimRecord(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, updated claimtypes.ClaimRecord) error { - require.Equal(t, newAddr.String(), updated.DestAddress) - return nil - }) - - err := f.keeper.MigrateClaim(f.ctx, legacy, newAddr) - require.NoError(t, err) -} - -// TestMigrateClaim_NotFound verifies no-op when there is no claim record. -func TestMigrateClaim_NotFound(t *testing.T) { - f := initMockFixture(t) - legacy := testAccAddr() - newAddr := testAccAddr() - - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()). - DoAndReturn(func(_ any, cb func(claimtypes.ClaimRecord) (bool, error)) error { - _, err := cb(claimtypes.ClaimRecord{ - OldAddress: "pastel1otheroldaddress", - DestAddress: testAccAddr().String(), - }) - return err - }) - - err := f.keeper.MigrateClaim(f.ctx, legacy, newAddr) - require.NoError(t, err) -} - // --- MigrateStaking tests --- // TestMigrateStaking_ActiveDelegations verifies the full staking migration flow: diff --git a/x/evmigration/keeper/msg_server_claim_legacy.go b/x/evmigration/keeper/msg_server_claim_legacy.go index 7d74af96..c905c8fe 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy.go +++ b/x/evmigration/keeper/msg_server_claim_legacy.go @@ -224,11 +224,6 @@ func (ms msgServer) migrateAccount(ctx sdk.Context, legacyAddr, newAddr sdk.AccA return fmt.Errorf("migrate actions: %w", err) } - // Step 8: Update claim destAddress. - if err := ms.MigrateClaim(ctx, legacyAddr, newAddr); err != nil { - return fmt.Errorf("migrate claim: %w", err) - } - return nil } diff --git a/x/evmigration/keeper/msg_server_claim_legacy_test.go b/x/evmigration/keeper/msg_server_claim_legacy_test.go index 87a20b8a..746f9468 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -106,7 +106,6 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { feegrantKeeper := evmigrationmocks.NewMockFeegrantKeeper(ctrl) supernodeKeeper := evmigrationmocks.NewMockSupernodeKeeper(ctrl) actionKeeper := evmigrationmocks.NewMockActionKeeper(ctrl) - claimKeeper := evmigrationmocks.NewMockClaimKeeper(ctrl) encCfg := moduletestutil.MakeTestEncodingConfig(module.AppModule{}) addrCodec := addresscodec.NewBech32Codec(sdk.GetConfig().GetBech32AccountAddrPrefix()) @@ -138,7 +137,6 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { feegrantKeeper, supernodeKeeper, actionKeeper, - claimKeeper, ) // Wire an isolated staking store service so scoped redelegation iteration @@ -165,7 +163,6 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { feegrantKeeper: feegrantKeeper, supernodeKeeper: supernodeKeeper, actionKeeper: actionKeeper, - claimKeeper: claimKeeper, } return &msgServerFixture{ @@ -489,9 +486,6 @@ func TestClaimLegacyAccount_Success(t *testing.T) { f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) - // Step 8: MigrateClaim — no claim records targeting this address. - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) - msg := newClaimMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) resp, err := f.msgServer.ClaimLegacyAccount(f.ctx, msg) @@ -577,7 +571,7 @@ func TestClaimLegacyAccount_MigratedThirdPartyWithdrawAddress(t *testing.T) { // Step 3b: MigrateBank — no balance. f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), legacyAddr).Return(sdk.Coins{}) - // Steps 4-8: no authz/feegrant/supernode/action/claim to migrate. + // Steps 4-7: no authz/feegrant/supernode/action to migrate. f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( @@ -585,7 +579,6 @@ func TestClaimLegacyAccount_MigratedThirdPartyWithdrawAddress(t *testing.T) { ) f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) msg := &types.MsgClaimLegacyAccount{ LegacyAddress: legacyAddr.String(), @@ -877,47 +870,6 @@ func TestClaimLegacyAccount_FailAtActions(t *testing.T) { assertNoFinalization(t, f, legacyAddr) } -// TestClaimLegacyAccount_FailAtClaim verifies that a failure in MigrateClaim -// (step 8, the last step before finalization) propagates and no record is stored. -func TestClaimLegacyAccount_FailAtClaim(t *testing.T) { - f := initMsgServerFixture(t) - _, legacyAddr, newAddr, msg := setupPassingPreChecks(t, f) - - // Steps 1-7 succeed. - f.stakingKeeper.EXPECT().GetDelegatorDelegations(gomock.Any(), legacyAddr, ^uint16(0)).Return(nil, nil).Times(2) - f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), legacyAddr, ^uint16(0)).Return(nil, nil) - f.stakingKeeper.EXPECT().GetRedelegations(gomock.Any(), legacyAddr, ^uint16(0)).Return(nil, nil) - f.distributionKeeper.EXPECT().GetDelegatorWithdrawAddr(gomock.Any(), legacyAddr).Return(legacyAddr, nil).Times(2) - f.distributionKeeper.EXPECT().SetDelegatorWithdrawAddr(gomock.Any(), newAddr, newAddr).Return(nil) - - baseAcc := authtypes.NewBaseAccountWithAddress(legacyAddr) - f.accountKeeper.EXPECT().GetAccount(gomock.Any(), legacyAddr).Return(baseAcc) - f.accountKeeper.EXPECT().RemoveAccount(gomock.Any(), baseAcc) - newAcc := authtypes.NewBaseAccountWithAddress(newAddr) - f.accountKeeper.EXPECT().GetAccount(gomock.Any(), newAddr).Return(nil) - f.accountKeeper.EXPECT().NewAccountWithAddress(gomock.Any(), newAddr).Return(newAcc) - f.accountKeeper.EXPECT().SetAccount(gomock.Any(), newAcc) - - f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), legacyAddr).Return(sdk.Coins{}) - f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) - f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) - f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( - sntypes.SuperNode{}, false, nil, - ) - f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) - f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) - - // Step 8: MigrateClaim fails. - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return( - fmt.Errorf("claim store corrupted"), - ) - - _, err := f.msgServer.ClaimLegacyAccount(f.ctx, msg) - require.Error(t, err) - require.Contains(t, err.Error(), "migrate claim") - assertNoFinalization(t, f, legacyAddr) -} - // --- MigrateValidator failure-path / atomicity tests --- // setupPassingValPreChecks configures mocks so that preChecks, validator-specific @@ -1276,7 +1228,7 @@ func TestClaimLegacyAccount_WithDelegations(t *testing.T) { // Step 3b: MigrateBank f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), legacyAddr).Return(sdk.Coins{}) - // Steps 4-8: no authz/feegrant/supernode/action/claim to migrate. + // Steps 4-7: no authz/feegrant/supernode/action to migrate. f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) f.supernodeKeeper.EXPECT().GetSuperNodeByAccount(gomock.Any(), legacyAddr.String()).Return( @@ -1284,7 +1236,6 @@ func TestClaimLegacyAccount_WithDelegations(t *testing.T) { ) f.actionKeeper.EXPECT().GetActionsByCreator(gomock.Any(), gomock.Any()).Return(nil, nil) f.actionKeeper.EXPECT().GetActionsBySuperNode(gomock.Any(), gomock.Any()).Return(nil, nil) - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) msg := &types.MsgClaimLegacyAccount{ LegacyAddress: legacyAddr.String(), diff --git a/x/evmigration/keeper/msg_server_migrate_validator.go b/x/evmigration/keeper/msg_server_migrate_validator.go index baea150b..36a97c3c 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator.go +++ b/x/evmigration/keeper/msg_server_migrate_validator.go @@ -53,8 +53,19 @@ func (ms msgServer) MigrateValidator(goCtx context.Context, msg *types.MsgMigrat return nil, types.ErrValidatorUnbonding.Wrapf("validator is jailed (status: %s); unjail before migration", val.Status.String()) } - // Reject if validator is unbonding or unbonded. - if val.Status == stakingtypes.Unbonding || val.Status == stakingtypes.Unbonded { + // Reject only Unbonding — NOT Unbonded. An Unbonding validator still holds a + // live entry in the SDK unbonding-validator queue (keyed by completion time → + // operator address). Step V8 deletes the old validator record, but the + // evmigration StakingKeeper interface has no validator-queue methods, so the + // old operator address would be orphaned in the queue and halt the chain when + // UnbondAllMatureValidators fails to find it at maturity. + // + // Unbonded is safe and IS the recovery path: the queue entry was already + // dequeued (that transition is what made the validator Unbonded), so there is + // nothing to orphan. All re-keying below applies unchanged, letting an operator + // who fell out of the active set on stake weight recover keys, funds, and + // rewards without having to re-enter the set. + if val.Status == stakingtypes.Unbonding { return nil, types.ErrValidatorUnbonding } @@ -195,8 +206,8 @@ func (ms msgServer) MigrateValidator(goCtx context.Context, msg *types.MsgMigrat // --- Step V7: Account-level migration (shared with MsgClaimLegacyAccount) --- // Migrates distribution rewards, staking positions (to OTHER validators), - // auth account (vesting-aware), bank balances, authz grants, feegrant - // allowances, and claim records. + // auth account (vesting-aware), bank balances, authz grants, and feegrant + // allowances. // Snapshot the original withdraw address before MigrateDistribution may // temporarily redirect it to self. @@ -243,11 +254,6 @@ func (ms msgServer) MigrateValidator(goCtx context.Context, msg *types.MsgMigrat return nil, fmt.Errorf("migrate feegrant: %w", err) } - // Update claim record destAddress from legacy to new address. - if err := ms.MigrateClaim(ctx, legacyAddr, newAddr); err != nil { - return nil, fmt.Errorf("migrate claim: %w", err) - } - // --- Step V8: Delete orphaned main validator KV row --- // After all re-keying has landed under newValAddr, drop the now-dead main // validator entry at oldValAddr so staking iteration paths don't observe diff --git a/x/evmigration/keeper/msg_server_migrate_validator_test.go b/x/evmigration/keeper/msg_server_migrate_validator_test.go index dbf14c9e..94a7c5d5 100644 --- a/x/evmigration/keeper/msg_server_migrate_validator_test.go +++ b/x/evmigration/keeper/msg_server_migrate_validator_test.go @@ -44,7 +44,10 @@ func TestMigrateValidator_NotValidator(t *testing.T) { } // TestMigrateValidator_UnbondingValidator verifies rejection when the validator -// is in unbonding or unbonded status. +// is still Unbonding — migration would orphan its live unbonding-validator-queue +// entry and halt the chain at maturity. Unbonded is deliberately NOT rejected; +// see integration TestMigrateValidator_UnbondedNotJailedSucceeds for that +// recovery path. func TestMigrateValidator_UnbondingValidator(t *testing.T) { f := initMsgServerFixture(t) @@ -343,9 +346,6 @@ func TestMigrateValidator_Success(t *testing.T) { // MigrateFeegrant — no allowances. f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) - // MigrateClaim — no claim records targeting this address. - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) - // Step V8: DeleteValidatorRecordNoHooks precondition — new validator exists. f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return(val, nil) @@ -539,10 +539,9 @@ func TestMigrateValidator_OperatorDelegationsToOtherValidators(t *testing.T) { f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), legacyAddr).Return(balances) f.bankKeeper.EXPECT().SendCoins(gomock.Any(), legacyAddr, newAddr, balances).Return(nil) - // MigrateAuthz, MigrateFeegrant, MigrateClaim — empty. + // MigrateAuthz, MigrateFeegrant — empty. f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) // Step V8: DeleteValidatorRecordNoHooks precondition — new validator exists. f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return(val, nil) @@ -706,7 +705,6 @@ func TestMigrateValidator_ThirdPartyWithdrawAddrPreserved(t *testing.T) { f.bankKeeper.EXPECT().SendCoins(gomock.Any(), legacyAddr, newAddr, balances).Return(nil) f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) - f.claimKeeper.EXPECT().IterateClaimRecords(gomock.Any(), gomock.Any()).Return(nil) // Step V8: DeleteValidatorRecordNoHooks precondition — new validator exists. f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), newValAddr).Return(val, nil) diff --git a/x/evmigration/keeper/query.go b/x/evmigration/keeper/query.go index 9758bd51..7fceb5a1 100644 --- a/x/evmigration/keeper/query.go +++ b/x/evmigration/keeper/query.go @@ -214,12 +214,15 @@ func (qs queryServer) MigrationEstimate(goCtx context.Context, req *types.QueryM "validator is jailed (status: %s); restart the node, wait for catch-up, then `lumerad tx slashing unjail`", strings.ToLower(strings.TrimPrefix(val.Status.String(), "BOND_STATUS_")), ) - case val.Status == stakingtypes.Unbonding || val.Status == stakingtypes.Unbonded: + case val.Status == stakingtypes.Unbonding: + // Reject only Unbonding — an Unbonding validator still holds a live + // unbonding-validator-queue entry keyed by the old operator address; + // migrating would orphan it and halt the chain at maturity. Once the + // unbonding period elapses the validator becomes Unbonded (queue entry + // dequeued) and IS migratable, so the guidance is to wait, not re-bond. + // Unbonded (not jailed) falls through to the default → would_succeed=true. resp.WouldSucceed = false - resp.RejectionReason = fmt.Sprintf( - "validator status is %s (not bonded); migration requires the validator to be in the active set", - strings.ToLower(strings.TrimPrefix(val.Status.String(), "BOND_STATUS_")), - ) + resp.RejectionReason = "validator is unbonding; wait for the unbonding period to complete, then migrate" default: resp.WouldSucceed = true } diff --git a/x/evmigration/keeper/query_test.go b/x/evmigration/keeper/query_test.go index 54d857a5..cf9361bf 100644 --- a/x/evmigration/keeper/query_test.go +++ b/x/evmigration/keeper/query_test.go @@ -393,6 +393,106 @@ func TestQueryMigrationEstimate_ValidatorUsesScopedRedelegationIndexesForLimit(t require.Equal(t, "too many delegators", resp.RejectionReason) } +// TestMigrationEstimate_ValidatorUnbondedNotJailed_WouldSucceed verifies that a +// validator that fell out of the active set purely on stake weight (Unbonded, +// NOT jailed) is reported as migratable. An Unbonded validator has already been +// dequeued from the unbonding-validator queue, so re-keying and deleting the old +// record cannot orphan a queue entry (the chain-halt risk that keeps Unbonding +// blocked). The estimate must therefore return would_succeed=true so operators +// who dropped out are not deadlocked out of recovering keys, funds, and rewards. +func TestMigrationEstimate_ValidatorUnbondedNotJailed_WouldSucceed(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + qs := keeper.NewQueryServerImpl(f.keeper) + + addr := testAccAddr() + valAddr := sdk.ValAddress(addr) + delegator := testAccAddr() + + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{ + OperatorAddress: valAddr.String(), + Status: stakingtypes.Unbonded, + Jailed: false, + }, nil, + ) + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), valAddr).Return( + []stakingtypes.Delegation{ + stakingtypes.NewDelegation(delegator.String(), valAddr.String(), math.LegacyNewDec(50)), + }, nil, + ) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), valAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetDelegatorDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetRedelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) + f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), addr).Return(sdk.Coins{}) + f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), valAddr).Return(sntypes.SuperNode{}, false) + f.accountKeeper.EXPECT().GetAccount(gomock.Any(), addr).Return(nil) + + resp, err := qs.MigrationEstimate(f.ctx, &types.QueryMigrationEstimateRequest{ + LegacyAddress: addr.String(), + }) + require.NoError(t, err) + require.True(t, resp.IsValidator) + require.Equal(t, stakingtypes.Unbonded.String(), resp.ValidatorStatus) + require.False(t, resp.ValidatorJailed) + require.True(t, resp.WouldSucceed, "unbonded (non-jailed) validator must be migratable") + require.Empty(t, resp.RejectionReason) +} + +// TestMigrationEstimate_ValidatorUnbonding_WouldFail verifies that a validator +// still in the unbonding-validator queue (Unbonding) is reported as NOT +// migratable. The evmigration StakingKeeper interface has no validator-queue +// methods, so re-keying + deleting the old record would orphan the live queue +// entry keyed by the old operator address, halting the chain at maturity. The +// estimate must therefore return would_succeed=false and tell the operator to +// wait for the unbonding period to complete (after which the validator becomes +// Unbonded and is migratable). +func TestMigrationEstimate_ValidatorUnbonding_WouldFail(t *testing.T) { + f := initMockFixture(t) + f.wireScopedMigrationStores() + qs := keeper.NewQueryServerImpl(f.keeper) + + addr := testAccAddr() + valAddr := sdk.ValAddress(addr) + delegator := testAccAddr() + + f.stakingKeeper.EXPECT().GetValidator(gomock.Any(), valAddr).Return( + stakingtypes.Validator{ + OperatorAddress: valAddr.String(), + Status: stakingtypes.Unbonding, + Jailed: false, + }, nil, + ) + f.stakingKeeper.EXPECT().GetValidatorDelegations(gomock.Any(), valAddr).Return( + []stakingtypes.Delegation{ + stakingtypes.NewDelegation(delegator.String(), valAddr.String(), math.LegacyNewDec(50)), + }, nil, + ) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), valAddr).Return(nil, nil) + f.stakingKeeper.EXPECT().GetDelegatorDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.stakingKeeper.EXPECT().GetRedelegations(gomock.Any(), addr, ^uint16(0)).Return(nil, nil) + f.authzKeeper.EXPECT().IterateGrants(gomock.Any(), gomock.Any()) + f.feegrantKeeper.EXPECT().IterateAllFeeAllowances(gomock.Any(), gomock.Any()).Return(nil) + f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) + f.bankKeeper.EXPECT().GetAllBalances(gomock.Any(), addr).Return(sdk.Coins{}) + f.supernodeKeeper.EXPECT().QuerySuperNode(gomock.Any(), valAddr).Return(sntypes.SuperNode{}, false) + f.accountKeeper.EXPECT().GetAccount(gomock.Any(), addr).Return(nil) + + resp, err := qs.MigrationEstimate(f.ctx, &types.QueryMigrationEstimateRequest{ + LegacyAddress: addr.String(), + }) + require.NoError(t, err) + require.True(t, resp.IsValidator) + require.Equal(t, stakingtypes.Unbonding.String(), resp.ValidatorStatus) + require.False(t, resp.WouldSucceed, "unbonding validator must remain blocked") + require.Contains(t, resp.RejectionReason, "unbonding period") +} + // --- LegacyAccounts query tests --- // TestQueryLegacyAccounts_WithSecp256k1 verifies that accounts with secp256k1 diff --git a/x/evmigration/mocks/expected_keepers_mock.go b/x/evmigration/mocks/expected_keepers_mock.go index 23a876f4..c86c1e74 100644 --- a/x/evmigration/mocks/expected_keepers_mock.go +++ b/x/evmigration/mocks/expected_keepers_mock.go @@ -20,12 +20,11 @@ import ( address "cosmossdk.io/core/address" feegrant "cosmossdk.io/x/feegrant" types "github.com/LumeraProtocol/lumera/x/action/v1/types" - types0 "github.com/LumeraProtocol/lumera/x/claim/types" - types1 "github.com/LumeraProtocol/lumera/x/supernode/v1/types" - types2 "github.com/cosmos/cosmos-sdk/types" + types0 "github.com/LumeraProtocol/lumera/x/supernode/v1/types" + types1 "github.com/cosmos/cosmos-sdk/types" authz "github.com/cosmos/cosmos-sdk/x/authz" - types3 "github.com/cosmos/cosmos-sdk/x/distribution/types" - types4 "github.com/cosmos/cosmos-sdk/x/staking/types" + types2 "github.com/cosmos/cosmos-sdk/x/distribution/types" + types3 "github.com/cosmos/cosmos-sdk/x/staking/types" gomock "go.uber.org/mock/gomock" ) @@ -68,10 +67,10 @@ func (mr *MockAccountKeeperMockRecorder) AddressCodec() *gomock.Call { } // GetAccount mocks base method. -func (m *MockAccountKeeper) GetAccount(ctx context.Context, addr types2.AccAddress) types2.AccountI { +func (m *MockAccountKeeper) GetAccount(ctx context.Context, addr types1.AccAddress) types1.AccountI { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAccount", ctx, addr) - ret0, _ := ret[0].(types2.AccountI) + ret0, _ := ret[0].(types1.AccountI) return ret0 } @@ -82,7 +81,7 @@ func (mr *MockAccountKeeperMockRecorder) GetAccount(ctx, addr any) *gomock.Call } // IterateAccounts mocks base method. -func (m *MockAccountKeeper) IterateAccounts(ctx context.Context, cb func(types2.AccountI) bool) { +func (m *MockAccountKeeper) IterateAccounts(ctx context.Context, cb func(types1.AccountI) bool) { m.ctrl.T.Helper() m.ctrl.Call(m, "IterateAccounts", ctx, cb) } @@ -94,10 +93,10 @@ func (mr *MockAccountKeeperMockRecorder) IterateAccounts(ctx, cb any) *gomock.Ca } // NewAccountWithAddress mocks base method. -func (m *MockAccountKeeper) NewAccountWithAddress(ctx context.Context, addr types2.AccAddress) types2.AccountI { +func (m *MockAccountKeeper) NewAccountWithAddress(ctx context.Context, addr types1.AccAddress) types1.AccountI { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewAccountWithAddress", ctx, addr) - ret0, _ := ret[0].(types2.AccountI) + ret0, _ := ret[0].(types1.AccountI) return ret0 } @@ -108,7 +107,7 @@ func (mr *MockAccountKeeperMockRecorder) NewAccountWithAddress(ctx, addr any) *g } // RemoveAccount mocks base method. -func (m *MockAccountKeeper) RemoveAccount(ctx context.Context, acc types2.AccountI) { +func (m *MockAccountKeeper) RemoveAccount(ctx context.Context, acc types1.AccountI) { m.ctrl.T.Helper() m.ctrl.Call(m, "RemoveAccount", ctx, acc) } @@ -120,7 +119,7 @@ func (mr *MockAccountKeeperMockRecorder) RemoveAccount(ctx, acc any) *gomock.Cal } // SetAccount mocks base method. -func (m *MockAccountKeeper) SetAccount(ctx context.Context, acc types2.AccountI) { +func (m *MockAccountKeeper) SetAccount(ctx context.Context, acc types1.AccountI) { m.ctrl.T.Helper() m.ctrl.Call(m, "SetAccount", ctx, acc) } @@ -156,7 +155,7 @@ func (m *MockBankKeeper) EXPECT() *MockBankKeeperMockRecorder { } // BlockedAddr mocks base method. -func (m *MockBankKeeper) BlockedAddr(addr types2.AccAddress) bool { +func (m *MockBankKeeper) BlockedAddr(addr types1.AccAddress) bool { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "BlockedAddr", addr) ret0, _ := ret[0].(bool) @@ -170,10 +169,10 @@ func (mr *MockBankKeeperMockRecorder) BlockedAddr(addr any) *gomock.Call { } // GetAllBalances mocks base method. -func (m *MockBankKeeper) GetAllBalances(ctx context.Context, addr types2.AccAddress) types2.Coins { +func (m *MockBankKeeper) GetAllBalances(ctx context.Context, addr types1.AccAddress) types1.Coins { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAllBalances", ctx, addr) - ret0, _ := ret[0].(types2.Coins) + ret0, _ := ret[0].(types1.Coins) return ret0 } @@ -184,7 +183,7 @@ func (mr *MockBankKeeperMockRecorder) GetAllBalances(ctx, addr any) *gomock.Call } // SendCoins mocks base method. -func (m *MockBankKeeper) SendCoins(ctx context.Context, fromAddr, toAddr types2.AccAddress, amt types2.Coins) error { +func (m *MockBankKeeper) SendCoins(ctx context.Context, fromAddr, toAddr types1.AccAddress, amt types1.Coins) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SendCoins", ctx, fromAddr, toAddr, amt) ret0, _ := ret[0].(error) @@ -237,7 +236,7 @@ func (mr *MockStakingKeeperMockRecorder) BondDenom(ctx any) *gomock.Call { } // DeleteLastValidatorPower mocks base method. -func (m *MockStakingKeeper) DeleteLastValidatorPower(ctx context.Context, operator types2.ValAddress) error { +func (m *MockStakingKeeper) DeleteLastValidatorPower(ctx context.Context, operator types1.ValAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteLastValidatorPower", ctx, operator) ret0, _ := ret[0].(error) @@ -251,7 +250,7 @@ func (mr *MockStakingKeeperMockRecorder) DeleteLastValidatorPower(ctx, operator } // DeleteValidatorByPowerIndex mocks base method. -func (m *MockStakingKeeper) DeleteValidatorByPowerIndex(ctx context.Context, validator types4.Validator) error { +func (m *MockStakingKeeper) DeleteValidatorByPowerIndex(ctx context.Context, validator types3.Validator) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteValidatorByPowerIndex", ctx, validator) ret0, _ := ret[0].(error) @@ -265,10 +264,10 @@ func (mr *MockStakingKeeperMockRecorder) DeleteValidatorByPowerIndex(ctx, valida } // GetDelegatorDelegations mocks base method. -func (m *MockStakingKeeper) GetDelegatorDelegations(ctx context.Context, delegator types2.AccAddress, maxRetrieve uint16) ([]types4.Delegation, error) { +func (m *MockStakingKeeper) GetDelegatorDelegations(ctx context.Context, delegator types1.AccAddress, maxRetrieve uint16) ([]types3.Delegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDelegatorDelegations", ctx, delegator, maxRetrieve) - ret0, _ := ret[0].([]types4.Delegation) + ret0, _ := ret[0].([]types3.Delegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -280,7 +279,7 @@ func (mr *MockStakingKeeperMockRecorder) GetDelegatorDelegations(ctx, delegator, } // GetLastValidatorPower mocks base method. -func (m *MockStakingKeeper) GetLastValidatorPower(ctx context.Context, operator types2.ValAddress) (int64, error) { +func (m *MockStakingKeeper) GetLastValidatorPower(ctx context.Context, operator types1.ValAddress) (int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetLastValidatorPower", ctx, operator) ret0, _ := ret[0].(int64) @@ -295,10 +294,10 @@ func (mr *MockStakingKeeperMockRecorder) GetLastValidatorPower(ctx, operator any } // GetRedelegations mocks base method. -func (m *MockStakingKeeper) GetRedelegations(ctx context.Context, delegator types2.AccAddress, maxRetrieve uint16) ([]types4.Redelegation, error) { +func (m *MockStakingKeeper) GetRedelegations(ctx context.Context, delegator types1.AccAddress, maxRetrieve uint16) ([]types3.Redelegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetRedelegations", ctx, delegator, maxRetrieve) - ret0, _ := ret[0].([]types4.Redelegation) + ret0, _ := ret[0].([]types3.Redelegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -310,10 +309,10 @@ func (mr *MockStakingKeeperMockRecorder) GetRedelegations(ctx, delegator, maxRet } // GetRedelegationsFromSrcValidator mocks base method. -func (m *MockStakingKeeper) GetRedelegationsFromSrcValidator(ctx context.Context, valAddr types2.ValAddress) ([]types4.Redelegation, error) { +func (m *MockStakingKeeper) GetRedelegationsFromSrcValidator(ctx context.Context, valAddr types1.ValAddress) ([]types3.Redelegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetRedelegationsFromSrcValidator", ctx, valAddr) - ret0, _ := ret[0].([]types4.Redelegation) + ret0, _ := ret[0].([]types3.Redelegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -325,10 +324,10 @@ func (mr *MockStakingKeeperMockRecorder) GetRedelegationsFromSrcValidator(ctx, v } // GetUnbondingDelegation mocks base method. -func (m *MockStakingKeeper) GetUnbondingDelegation(ctx context.Context, delAddr types2.AccAddress, valAddr types2.ValAddress) (types4.UnbondingDelegation, error) { +func (m *MockStakingKeeper) GetUnbondingDelegation(ctx context.Context, delAddr types1.AccAddress, valAddr types1.ValAddress) (types3.UnbondingDelegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUnbondingDelegation", ctx, delAddr, valAddr) - ret0, _ := ret[0].(types4.UnbondingDelegation) + ret0, _ := ret[0].(types3.UnbondingDelegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -340,10 +339,10 @@ func (mr *MockStakingKeeperMockRecorder) GetUnbondingDelegation(ctx, delAddr, va } // GetUnbondingDelegations mocks base method. -func (m *MockStakingKeeper) GetUnbondingDelegations(ctx context.Context, delegator types2.AccAddress, maxRetrieve uint16) ([]types4.UnbondingDelegation, error) { +func (m *MockStakingKeeper) GetUnbondingDelegations(ctx context.Context, delegator types1.AccAddress, maxRetrieve uint16) ([]types3.UnbondingDelegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUnbondingDelegations", ctx, delegator, maxRetrieve) - ret0, _ := ret[0].([]types4.UnbondingDelegation) + ret0, _ := ret[0].([]types3.UnbondingDelegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -355,10 +354,10 @@ func (mr *MockStakingKeeperMockRecorder) GetUnbondingDelegations(ctx, delegator, } // GetUnbondingDelegationsFromValidator mocks base method. -func (m *MockStakingKeeper) GetUnbondingDelegationsFromValidator(ctx context.Context, valAddr types2.ValAddress) ([]types4.UnbondingDelegation, error) { +func (m *MockStakingKeeper) GetUnbondingDelegationsFromValidator(ctx context.Context, valAddr types1.ValAddress) ([]types3.UnbondingDelegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetUnbondingDelegationsFromValidator", ctx, valAddr) - ret0, _ := ret[0].([]types4.UnbondingDelegation) + ret0, _ := ret[0].([]types3.UnbondingDelegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -370,10 +369,10 @@ func (mr *MockStakingKeeperMockRecorder) GetUnbondingDelegationsFromValidator(ct } // GetValidator mocks base method. -func (m *MockStakingKeeper) GetValidator(ctx context.Context, addr types2.ValAddress) (types4.Validator, error) { +func (m *MockStakingKeeper) GetValidator(ctx context.Context, addr types1.ValAddress) (types3.Validator, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidator", ctx, addr) - ret0, _ := ret[0].(types4.Validator) + ret0, _ := ret[0].(types3.Validator) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -385,10 +384,10 @@ func (mr *MockStakingKeeperMockRecorder) GetValidator(ctx, addr any) *gomock.Cal } // GetValidatorDelegations mocks base method. -func (m *MockStakingKeeper) GetValidatorDelegations(ctx context.Context, valAddr types2.ValAddress) ([]types4.Delegation, error) { +func (m *MockStakingKeeper) GetValidatorDelegations(ctx context.Context, valAddr types1.ValAddress) ([]types3.Delegation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatorDelegations", ctx, valAddr) - ret0, _ := ret[0].([]types4.Delegation) + ret0, _ := ret[0].([]types3.Delegation) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -400,7 +399,7 @@ func (mr *MockStakingKeeperMockRecorder) GetValidatorDelegations(ctx, valAddr an } // InsertRedelegationQueue mocks base method. -func (m *MockStakingKeeper) InsertRedelegationQueue(ctx context.Context, red types4.Redelegation, completionTime time.Time) error { +func (m *MockStakingKeeper) InsertRedelegationQueue(ctx context.Context, red types3.Redelegation, completionTime time.Time) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertRedelegationQueue", ctx, red, completionTime) ret0, _ := ret[0].(error) @@ -414,7 +413,7 @@ func (mr *MockStakingKeeperMockRecorder) InsertRedelegationQueue(ctx, red, compl } // InsertUBDQueue mocks base method. -func (m *MockStakingKeeper) InsertUBDQueue(ctx context.Context, ubd types4.UnbondingDelegation, completionTime time.Time) error { +func (m *MockStakingKeeper) InsertUBDQueue(ctx context.Context, ubd types3.UnbondingDelegation, completionTime time.Time) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InsertUBDQueue", ctx, ubd, completionTime) ret0, _ := ret[0].(error) @@ -428,7 +427,7 @@ func (mr *MockStakingKeeperMockRecorder) InsertUBDQueue(ctx, ubd, completionTime } // IterateRedelegations mocks base method. -func (m *MockStakingKeeper) IterateRedelegations(ctx context.Context, fn func(int64, types4.Redelegation) bool) error { +func (m *MockStakingKeeper) IterateRedelegations(ctx context.Context, fn func(int64, types3.Redelegation) bool) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IterateRedelegations", ctx, fn) ret0, _ := ret[0].(error) @@ -442,7 +441,7 @@ func (mr *MockStakingKeeperMockRecorder) IterateRedelegations(ctx, fn any) *gomo } // RemoveDelegation mocks base method. -func (m *MockStakingKeeper) RemoveDelegation(ctx context.Context, delegation types4.Delegation) error { +func (m *MockStakingKeeper) RemoveDelegation(ctx context.Context, delegation types3.Delegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveDelegation", ctx, delegation) ret0, _ := ret[0].(error) @@ -456,7 +455,7 @@ func (mr *MockStakingKeeperMockRecorder) RemoveDelegation(ctx, delegation any) * } // RemoveRedelegation mocks base method. -func (m *MockStakingKeeper) RemoveRedelegation(ctx context.Context, red types4.Redelegation) error { +func (m *MockStakingKeeper) RemoveRedelegation(ctx context.Context, red types3.Redelegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveRedelegation", ctx, red) ret0, _ := ret[0].(error) @@ -470,7 +469,7 @@ func (mr *MockStakingKeeperMockRecorder) RemoveRedelegation(ctx, red any) *gomoc } // RemoveUnbondingDelegation mocks base method. -func (m *MockStakingKeeper) RemoveUnbondingDelegation(ctx context.Context, ubd types4.UnbondingDelegation) error { +func (m *MockStakingKeeper) RemoveUnbondingDelegation(ctx context.Context, ubd types3.UnbondingDelegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveUnbondingDelegation", ctx, ubd) ret0, _ := ret[0].(error) @@ -484,7 +483,7 @@ func (mr *MockStakingKeeperMockRecorder) RemoveUnbondingDelegation(ctx, ubd any) } // SetDelegation mocks base method. -func (m *MockStakingKeeper) SetDelegation(ctx context.Context, delegation types4.Delegation) error { +func (m *MockStakingKeeper) SetDelegation(ctx context.Context, delegation types3.Delegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetDelegation", ctx, delegation) ret0, _ := ret[0].(error) @@ -498,7 +497,7 @@ func (mr *MockStakingKeeperMockRecorder) SetDelegation(ctx, delegation any) *gom } // SetLastValidatorPower mocks base method. -func (m *MockStakingKeeper) SetLastValidatorPower(ctx context.Context, operator types2.ValAddress, power int64) error { +func (m *MockStakingKeeper) SetLastValidatorPower(ctx context.Context, operator types1.ValAddress, power int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetLastValidatorPower", ctx, operator, power) ret0, _ := ret[0].(error) @@ -512,7 +511,7 @@ func (mr *MockStakingKeeperMockRecorder) SetLastValidatorPower(ctx, operator, po } // SetRedelegation mocks base method. -func (m *MockStakingKeeper) SetRedelegation(ctx context.Context, red types4.Redelegation) error { +func (m *MockStakingKeeper) SetRedelegation(ctx context.Context, red types3.Redelegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetRedelegation", ctx, red) ret0, _ := ret[0].(error) @@ -526,7 +525,7 @@ func (mr *MockStakingKeeperMockRecorder) SetRedelegation(ctx, red any) *gomock.C } // SetRedelegationByUnbondingID mocks base method. -func (m *MockStakingKeeper) SetRedelegationByUnbondingID(ctx context.Context, red types4.Redelegation, id uint64) error { +func (m *MockStakingKeeper) SetRedelegationByUnbondingID(ctx context.Context, red types3.Redelegation, id uint64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetRedelegationByUnbondingID", ctx, red, id) ret0, _ := ret[0].(error) @@ -540,7 +539,7 @@ func (mr *MockStakingKeeperMockRecorder) SetRedelegationByUnbondingID(ctx, red, } // SetUnbondingDelegation mocks base method. -func (m *MockStakingKeeper) SetUnbondingDelegation(ctx context.Context, ubd types4.UnbondingDelegation) error { +func (m *MockStakingKeeper) SetUnbondingDelegation(ctx context.Context, ubd types3.UnbondingDelegation) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUnbondingDelegation", ctx, ubd) ret0, _ := ret[0].(error) @@ -554,7 +553,7 @@ func (mr *MockStakingKeeperMockRecorder) SetUnbondingDelegation(ctx, ubd any) *g } // SetUnbondingDelegationByUnbondingID mocks base method. -func (m *MockStakingKeeper) SetUnbondingDelegationByUnbondingID(ctx context.Context, ubd types4.UnbondingDelegation, id uint64) error { +func (m *MockStakingKeeper) SetUnbondingDelegationByUnbondingID(ctx context.Context, ubd types3.UnbondingDelegation, id uint64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetUnbondingDelegationByUnbondingID", ctx, ubd, id) ret0, _ := ret[0].(error) @@ -568,7 +567,7 @@ func (mr *MockStakingKeeperMockRecorder) SetUnbondingDelegationByUnbondingID(ctx } // SetValidator mocks base method. -func (m *MockStakingKeeper) SetValidator(ctx context.Context, validator types4.Validator) error { +func (m *MockStakingKeeper) SetValidator(ctx context.Context, validator types3.Validator) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidator", ctx, validator) ret0, _ := ret[0].(error) @@ -582,7 +581,7 @@ func (mr *MockStakingKeeperMockRecorder) SetValidator(ctx, validator any) *gomoc } // SetValidatorByConsAddr mocks base method. -func (m *MockStakingKeeper) SetValidatorByConsAddr(ctx context.Context, validator types4.Validator) error { +func (m *MockStakingKeeper) SetValidatorByConsAddr(ctx context.Context, validator types3.Validator) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorByConsAddr", ctx, validator) ret0, _ := ret[0].(error) @@ -596,7 +595,7 @@ func (mr *MockStakingKeeperMockRecorder) SetValidatorByConsAddr(ctx, validator a } // SetValidatorByPowerIndex mocks base method. -func (m *MockStakingKeeper) SetValidatorByPowerIndex(ctx context.Context, validator types4.Validator) error { +func (m *MockStakingKeeper) SetValidatorByPowerIndex(ctx context.Context, validator types3.Validator) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorByPowerIndex", ctx, validator) ret0, _ := ret[0].(error) @@ -610,10 +609,10 @@ func (mr *MockStakingKeeperMockRecorder) SetValidatorByPowerIndex(ctx, validator } // ValidatorByConsAddr mocks base method. -func (m *MockStakingKeeper) ValidatorByConsAddr(ctx context.Context, consAddr types2.ConsAddress) (types4.ValidatorI, error) { +func (m *MockStakingKeeper) ValidatorByConsAddr(ctx context.Context, consAddr types1.ConsAddress) (types3.ValidatorI, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ValidatorByConsAddr", ctx, consAddr) - ret0, _ := ret[0].(types4.ValidatorI) + ret0, _ := ret[0].(types3.ValidatorI) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -649,7 +648,7 @@ func (m *MockDistributionKeeper) EXPECT() *MockDistributionKeeperMockRecorder { } // DeleteDelegatorStartingInfo mocks base method. -func (m *MockDistributionKeeper) DeleteDelegatorStartingInfo(ctx context.Context, val types2.ValAddress, del types2.AccAddress) error { +func (m *MockDistributionKeeper) DeleteDelegatorStartingInfo(ctx context.Context, val types1.ValAddress, del types1.AccAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteDelegatorStartingInfo", ctx, val, del) ret0, _ := ret[0].(error) @@ -663,7 +662,7 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteDelegatorStartingInfo(ctx, v } // DeleteValidatorAccumulatedCommission mocks base method. -func (m *MockDistributionKeeper) DeleteValidatorAccumulatedCommission(ctx context.Context, val types2.ValAddress) error { +func (m *MockDistributionKeeper) DeleteValidatorAccumulatedCommission(ctx context.Context, val types1.ValAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteValidatorAccumulatedCommission", ctx, val) ret0, _ := ret[0].(error) @@ -677,7 +676,7 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteValidatorAccumulatedCommissi } // DeleteValidatorCurrentRewards mocks base method. -func (m *MockDistributionKeeper) DeleteValidatorCurrentRewards(ctx context.Context, val types2.ValAddress) error { +func (m *MockDistributionKeeper) DeleteValidatorCurrentRewards(ctx context.Context, val types1.ValAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteValidatorCurrentRewards", ctx, val) ret0, _ := ret[0].(error) @@ -691,7 +690,7 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteValidatorCurrentRewards(ctx, } // DeleteValidatorHistoricalRewards mocks base method. -func (m *MockDistributionKeeper) DeleteValidatorHistoricalRewards(ctx context.Context, val types2.ValAddress) { +func (m *MockDistributionKeeper) DeleteValidatorHistoricalRewards(ctx context.Context, val types1.ValAddress) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteValidatorHistoricalRewards", ctx, val) } @@ -703,7 +702,7 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteValidatorHistoricalRewards(c } // DeleteValidatorOutstandingRewards mocks base method. -func (m *MockDistributionKeeper) DeleteValidatorOutstandingRewards(ctx context.Context, val types2.ValAddress) error { +func (m *MockDistributionKeeper) DeleteValidatorOutstandingRewards(ctx context.Context, val types1.ValAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteValidatorOutstandingRewards", ctx, val) ret0, _ := ret[0].(error) @@ -717,7 +716,7 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteValidatorOutstandingRewards( } // DeleteValidatorSlashEvents mocks base method. -func (m *MockDistributionKeeper) DeleteValidatorSlashEvents(ctx context.Context, val types2.ValAddress) { +func (m *MockDistributionKeeper) DeleteValidatorSlashEvents(ctx context.Context, val types1.ValAddress) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteValidatorSlashEvents", ctx, val) } @@ -729,10 +728,10 @@ func (mr *MockDistributionKeeperMockRecorder) DeleteValidatorSlashEvents(ctx, va } // GetDelegatorStartingInfo mocks base method. -func (m *MockDistributionKeeper) GetDelegatorStartingInfo(ctx context.Context, val types2.ValAddress, del types2.AccAddress) (types3.DelegatorStartingInfo, error) { +func (m *MockDistributionKeeper) GetDelegatorStartingInfo(ctx context.Context, val types1.ValAddress, del types1.AccAddress) (types2.DelegatorStartingInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDelegatorStartingInfo", ctx, val, del) - ret0, _ := ret[0].(types3.DelegatorStartingInfo) + ret0, _ := ret[0].(types2.DelegatorStartingInfo) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -744,10 +743,10 @@ func (mr *MockDistributionKeeperMockRecorder) GetDelegatorStartingInfo(ctx, val, } // GetDelegatorWithdrawAddr mocks base method. -func (m *MockDistributionKeeper) GetDelegatorWithdrawAddr(ctx context.Context, delAddr types2.AccAddress) (types2.AccAddress, error) { +func (m *MockDistributionKeeper) GetDelegatorWithdrawAddr(ctx context.Context, delAddr types1.AccAddress) (types1.AccAddress, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetDelegatorWithdrawAddr", ctx, delAddr) - ret0, _ := ret[0].(types2.AccAddress) + ret0, _ := ret[0].(types1.AccAddress) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -759,10 +758,10 @@ func (mr *MockDistributionKeeperMockRecorder) GetDelegatorWithdrawAddr(ctx, delA } // GetValidatorAccumulatedCommission mocks base method. -func (m *MockDistributionKeeper) GetValidatorAccumulatedCommission(ctx context.Context, val types2.ValAddress) (types3.ValidatorAccumulatedCommission, error) { +func (m *MockDistributionKeeper) GetValidatorAccumulatedCommission(ctx context.Context, val types1.ValAddress) (types2.ValidatorAccumulatedCommission, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatorAccumulatedCommission", ctx, val) - ret0, _ := ret[0].(types3.ValidatorAccumulatedCommission) + ret0, _ := ret[0].(types2.ValidatorAccumulatedCommission) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -774,10 +773,10 @@ func (mr *MockDistributionKeeperMockRecorder) GetValidatorAccumulatedCommission( } // GetValidatorCurrentRewards mocks base method. -func (m *MockDistributionKeeper) GetValidatorCurrentRewards(ctx context.Context, val types2.ValAddress) (types3.ValidatorCurrentRewards, error) { +func (m *MockDistributionKeeper) GetValidatorCurrentRewards(ctx context.Context, val types1.ValAddress) (types2.ValidatorCurrentRewards, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatorCurrentRewards", ctx, val) - ret0, _ := ret[0].(types3.ValidatorCurrentRewards) + ret0, _ := ret[0].(types2.ValidatorCurrentRewards) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -789,10 +788,10 @@ func (mr *MockDistributionKeeperMockRecorder) GetValidatorCurrentRewards(ctx, va } // GetValidatorOutstandingRewards mocks base method. -func (m *MockDistributionKeeper) GetValidatorOutstandingRewards(ctx context.Context, val types2.ValAddress) (types3.ValidatorOutstandingRewards, error) { +func (m *MockDistributionKeeper) GetValidatorOutstandingRewards(ctx context.Context, val types1.ValAddress) (types2.ValidatorOutstandingRewards, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatorOutstandingRewards", ctx, val) - ret0, _ := ret[0].(types3.ValidatorOutstandingRewards) + ret0, _ := ret[0].(types2.ValidatorOutstandingRewards) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -804,7 +803,7 @@ func (mr *MockDistributionKeeperMockRecorder) GetValidatorOutstandingRewards(ctx } // IterateValidatorHistoricalRewards mocks base method. -func (m *MockDistributionKeeper) IterateValidatorHistoricalRewards(ctx context.Context, handler func(types2.ValAddress, uint64, types3.ValidatorHistoricalRewards) bool) { +func (m *MockDistributionKeeper) IterateValidatorHistoricalRewards(ctx context.Context, handler func(types1.ValAddress, uint64, types2.ValidatorHistoricalRewards) bool) { m.ctrl.T.Helper() m.ctrl.Call(m, "IterateValidatorHistoricalRewards", ctx, handler) } @@ -816,7 +815,7 @@ func (mr *MockDistributionKeeperMockRecorder) IterateValidatorHistoricalRewards( } // IterateValidatorSlashEvents mocks base method. -func (m *MockDistributionKeeper) IterateValidatorSlashEvents(ctx context.Context, handler func(types2.ValAddress, uint64, types3.ValidatorSlashEvent) bool) { +func (m *MockDistributionKeeper) IterateValidatorSlashEvents(ctx context.Context, handler func(types1.ValAddress, uint64, types2.ValidatorSlashEvent) bool) { m.ctrl.T.Helper() m.ctrl.Call(m, "IterateValidatorSlashEvents", ctx, handler) } @@ -828,7 +827,7 @@ func (mr *MockDistributionKeeperMockRecorder) IterateValidatorSlashEvents(ctx, h } // SetDelegatorStartingInfo mocks base method. -func (m *MockDistributionKeeper) SetDelegatorStartingInfo(ctx context.Context, val types2.ValAddress, del types2.AccAddress, period types3.DelegatorStartingInfo) error { +func (m *MockDistributionKeeper) SetDelegatorStartingInfo(ctx context.Context, val types1.ValAddress, del types1.AccAddress, period types2.DelegatorStartingInfo) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetDelegatorStartingInfo", ctx, val, del, period) ret0, _ := ret[0].(error) @@ -842,7 +841,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetDelegatorStartingInfo(ctx, val, } // SetDelegatorWithdrawAddr mocks base method. -func (m *MockDistributionKeeper) SetDelegatorWithdrawAddr(ctx context.Context, delAddr, withdrawAddr types2.AccAddress) error { +func (m *MockDistributionKeeper) SetDelegatorWithdrawAddr(ctx context.Context, delAddr, withdrawAddr types1.AccAddress) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetDelegatorWithdrawAddr", ctx, delAddr, withdrawAddr) ret0, _ := ret[0].(error) @@ -856,7 +855,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetDelegatorWithdrawAddr(ctx, delA } // SetValidatorAccumulatedCommission mocks base method. -func (m *MockDistributionKeeper) SetValidatorAccumulatedCommission(ctx context.Context, val types2.ValAddress, commission types3.ValidatorAccumulatedCommission) error { +func (m *MockDistributionKeeper) SetValidatorAccumulatedCommission(ctx context.Context, val types1.ValAddress, commission types2.ValidatorAccumulatedCommission) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorAccumulatedCommission", ctx, val, commission) ret0, _ := ret[0].(error) @@ -870,7 +869,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetValidatorAccumulatedCommission( } // SetValidatorCurrentRewards mocks base method. -func (m *MockDistributionKeeper) SetValidatorCurrentRewards(ctx context.Context, val types2.ValAddress, rewards types3.ValidatorCurrentRewards) error { +func (m *MockDistributionKeeper) SetValidatorCurrentRewards(ctx context.Context, val types1.ValAddress, rewards types2.ValidatorCurrentRewards) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorCurrentRewards", ctx, val, rewards) ret0, _ := ret[0].(error) @@ -884,7 +883,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetValidatorCurrentRewards(ctx, va } // SetValidatorHistoricalRewards mocks base method. -func (m *MockDistributionKeeper) SetValidatorHistoricalRewards(ctx context.Context, val types2.ValAddress, period uint64, rewards types3.ValidatorHistoricalRewards) error { +func (m *MockDistributionKeeper) SetValidatorHistoricalRewards(ctx context.Context, val types1.ValAddress, period uint64, rewards types2.ValidatorHistoricalRewards) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorHistoricalRewards", ctx, val, period, rewards) ret0, _ := ret[0].(error) @@ -898,7 +897,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetValidatorHistoricalRewards(ctx, } // SetValidatorOutstandingRewards mocks base method. -func (m *MockDistributionKeeper) SetValidatorOutstandingRewards(ctx context.Context, val types2.ValAddress, rewards types3.ValidatorOutstandingRewards) error { +func (m *MockDistributionKeeper) SetValidatorOutstandingRewards(ctx context.Context, val types1.ValAddress, rewards types2.ValidatorOutstandingRewards) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorOutstandingRewards", ctx, val, rewards) ret0, _ := ret[0].(error) @@ -912,7 +911,7 @@ func (mr *MockDistributionKeeperMockRecorder) SetValidatorOutstandingRewards(ctx } // SetValidatorSlashEvent mocks base method. -func (m *MockDistributionKeeper) SetValidatorSlashEvent(ctx context.Context, val types2.ValAddress, height, period uint64, event types3.ValidatorSlashEvent) error { +func (m *MockDistributionKeeper) SetValidatorSlashEvent(ctx context.Context, val types1.ValAddress, height, period uint64, event types2.ValidatorSlashEvent) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetValidatorSlashEvent", ctx, val, height, period, event) ret0, _ := ret[0].(error) @@ -926,10 +925,10 @@ func (mr *MockDistributionKeeperMockRecorder) SetValidatorSlashEvent(ctx, val, h } // WithdrawDelegationRewards mocks base method. -func (m *MockDistributionKeeper) WithdrawDelegationRewards(ctx context.Context, delAddr types2.AccAddress, valAddr types2.ValAddress) (types2.Coins, error) { +func (m *MockDistributionKeeper) WithdrawDelegationRewards(ctx context.Context, delAddr types1.AccAddress, valAddr types1.ValAddress) (types1.Coins, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "WithdrawDelegationRewards", ctx, delAddr, valAddr) - ret0, _ := ret[0].(types2.Coins) + ret0, _ := ret[0].(types1.Coins) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -941,10 +940,10 @@ func (mr *MockDistributionKeeperMockRecorder) WithdrawDelegationRewards(ctx, del } // WithdrawValidatorCommission mocks base method. -func (m *MockDistributionKeeper) WithdrawValidatorCommission(ctx context.Context, valAddr types2.ValAddress) (types2.Coins, error) { +func (m *MockDistributionKeeper) WithdrawValidatorCommission(ctx context.Context, valAddr types1.ValAddress) (types1.Coins, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "WithdrawValidatorCommission", ctx, valAddr) - ret0, _ := ret[0].(types2.Coins) + ret0, _ := ret[0].(types1.Coins) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -980,7 +979,7 @@ func (m *MockAuthzKeeper) EXPECT() *MockAuthzKeeperMockRecorder { } // DeleteGrant mocks base method. -func (m *MockAuthzKeeper) DeleteGrant(ctx context.Context, grantee, granter types2.AccAddress, msgType string) error { +func (m *MockAuthzKeeper) DeleteGrant(ctx context.Context, grantee, granter types1.AccAddress, msgType string) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteGrant", ctx, grantee, granter, msgType) ret0, _ := ret[0].(error) @@ -994,7 +993,7 @@ func (mr *MockAuthzKeeperMockRecorder) DeleteGrant(ctx, grantee, granter, msgTyp } // GetAuthorizations mocks base method. -func (m *MockAuthzKeeper) GetAuthorizations(ctx context.Context, grantee, granter types2.AccAddress) ([]authz.Authorization, error) { +func (m *MockAuthzKeeper) GetAuthorizations(ctx context.Context, grantee, granter types1.AccAddress) ([]authz.Authorization, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAuthorizations", ctx, grantee, granter) ret0, _ := ret[0].([]authz.Authorization) @@ -1009,7 +1008,7 @@ func (mr *MockAuthzKeeperMockRecorder) GetAuthorizations(ctx, grantee, granter a } // IterateGrants mocks base method. -func (m *MockAuthzKeeper) IterateGrants(ctx context.Context, handler func(types2.AccAddress, types2.AccAddress, authz.Grant) bool) { +func (m *MockAuthzKeeper) IterateGrants(ctx context.Context, handler func(types1.AccAddress, types1.AccAddress, authz.Grant) bool) { m.ctrl.T.Helper() m.ctrl.Call(m, "IterateGrants", ctx, handler) } @@ -1021,7 +1020,7 @@ func (mr *MockAuthzKeeperMockRecorder) IterateGrants(ctx, handler any) *gomock.C } // SaveGrant mocks base method. -func (m *MockAuthzKeeper) SaveGrant(ctx context.Context, grantee, granter types2.AccAddress, authorization authz.Authorization, expiration *time.Time) error { +func (m *MockAuthzKeeper) SaveGrant(ctx context.Context, grantee, granter types1.AccAddress, authorization authz.Authorization, expiration *time.Time) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SaveGrant", ctx, grantee, granter, authorization, expiration) ret0, _ := ret[0].(error) @@ -1059,7 +1058,7 @@ func (m *MockFeegrantKeeper) EXPECT() *MockFeegrantKeeperMockRecorder { } // GrantAllowance mocks base method. -func (m *MockFeegrantKeeper) GrantAllowance(ctx context.Context, granter, grantee types2.AccAddress, feeAllowance feegrant.FeeAllowanceI) error { +func (m *MockFeegrantKeeper) GrantAllowance(ctx context.Context, granter, grantee types1.AccAddress, feeAllowance feegrant.FeeAllowanceI) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GrantAllowance", ctx, granter, grantee, feeAllowance) ret0, _ := ret[0].(error) @@ -1111,7 +1110,7 @@ func (m *MockSupernodeKeeper) EXPECT() *MockSupernodeKeeperMockRecorder { } // DeleteMetricsState mocks base method. -func (m *MockSupernodeKeeper) DeleteMetricsState(ctx types2.Context, valAddr types2.ValAddress) { +func (m *MockSupernodeKeeper) DeleteMetricsState(ctx types1.Context, valAddr types1.ValAddress) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteMetricsState", ctx, valAddr) } @@ -1123,7 +1122,7 @@ func (mr *MockSupernodeKeeperMockRecorder) DeleteMetricsState(ctx, valAddr any) } // DeleteSuperNode mocks base method. -func (m *MockSupernodeKeeper) DeleteSuperNode(ctx types2.Context, valAddr types2.ValAddress) { +func (m *MockSupernodeKeeper) DeleteSuperNode(ctx types1.Context, valAddr types1.ValAddress) { m.ctrl.T.Helper() m.ctrl.Call(m, "DeleteSuperNode", ctx, valAddr) } @@ -1135,10 +1134,10 @@ func (mr *MockSupernodeKeeperMockRecorder) DeleteSuperNode(ctx, valAddr any) *go } // GetMetricsState mocks base method. -func (m *MockSupernodeKeeper) GetMetricsState(ctx types2.Context, valAddr types2.ValAddress) (types1.SupernodeMetricsState, bool) { +func (m *MockSupernodeKeeper) GetMetricsState(ctx types1.Context, valAddr types1.ValAddress) (types0.SupernodeMetricsState, bool) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetMetricsState", ctx, valAddr) - ret0, _ := ret[0].(types1.SupernodeMetricsState) + ret0, _ := ret[0].(types0.SupernodeMetricsState) ret1, _ := ret[1].(bool) return ret0, ret1 } @@ -1150,10 +1149,10 @@ func (mr *MockSupernodeKeeperMockRecorder) GetMetricsState(ctx, valAddr any) *go } // GetSuperNodeByAccount mocks base method. -func (m *MockSupernodeKeeper) GetSuperNodeByAccount(ctx types2.Context, supernodeAccount string) (types1.SuperNode, bool, error) { +func (m *MockSupernodeKeeper) GetSuperNodeByAccount(ctx types1.Context, supernodeAccount string) (types0.SuperNode, bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSuperNodeByAccount", ctx, supernodeAccount) - ret0, _ := ret[0].(types1.SuperNode) + ret0, _ := ret[0].(types0.SuperNode) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) return ret0, ret1, ret2 @@ -1166,10 +1165,10 @@ func (mr *MockSupernodeKeeperMockRecorder) GetSuperNodeByAccount(ctx, supernodeA } // QuerySuperNode mocks base method. -func (m *MockSupernodeKeeper) QuerySuperNode(ctx types2.Context, valOperAddr types2.ValAddress) (types1.SuperNode, bool) { +func (m *MockSupernodeKeeper) QuerySuperNode(ctx types1.Context, valOperAddr types1.ValAddress) (types0.SuperNode, bool) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "QuerySuperNode", ctx, valOperAddr) - ret0, _ := ret[0].(types1.SuperNode) + ret0, _ := ret[0].(types0.SuperNode) ret1, _ := ret[1].(bool) return ret0, ret1 } @@ -1181,7 +1180,7 @@ func (mr *MockSupernodeKeeperMockRecorder) QuerySuperNode(ctx, valOperAddr any) } // SetMetricsState mocks base method. -func (m *MockSupernodeKeeper) SetMetricsState(ctx types2.Context, state types1.SupernodeMetricsState) error { +func (m *MockSupernodeKeeper) SetMetricsState(ctx types1.Context, state types0.SupernodeMetricsState) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetMetricsState", ctx, state) ret0, _ := ret[0].(error) @@ -1195,7 +1194,7 @@ func (mr *MockSupernodeKeeperMockRecorder) SetMetricsState(ctx, state any) *gomo } // SetSuperNode mocks base method. -func (m *MockSupernodeKeeper) SetSuperNode(ctx types2.Context, supernode types1.SuperNode) error { +func (m *MockSupernodeKeeper) SetSuperNode(ctx types1.Context, supernode types0.SuperNode) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetSuperNode", ctx, supernode) ret0, _ := ret[0].(error) @@ -1233,7 +1232,7 @@ func (m *MockActionKeeper) EXPECT() *MockActionKeeperMockRecorder { } // GetActionByID mocks base method. -func (m *MockActionKeeper) GetActionByID(ctx types2.Context, actionID string) (*types.Action, bool) { +func (m *MockActionKeeper) GetActionByID(ctx types1.Context, actionID string) (*types.Action, bool) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActionByID", ctx, actionID) ret0, _ := ret[0].(*types.Action) @@ -1248,7 +1247,7 @@ func (mr *MockActionKeeperMockRecorder) GetActionByID(ctx, actionID any) *gomock } // GetActionsByCreator mocks base method. -func (m *MockActionKeeper) GetActionsByCreator(ctx types2.Context, creator string) ([]*types.Action, error) { +func (m *MockActionKeeper) GetActionsByCreator(ctx types1.Context, creator string) ([]*types.Action, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActionsByCreator", ctx, creator) ret0, _ := ret[0].([]*types.Action) @@ -1263,7 +1262,7 @@ func (mr *MockActionKeeperMockRecorder) GetActionsByCreator(ctx, creator any) *g } // GetActionsBySuperNode mocks base method. -func (m *MockActionKeeper) GetActionsBySuperNode(ctx types2.Context, supernode string) ([]*types.Action, error) { +func (m *MockActionKeeper) GetActionsBySuperNode(ctx types1.Context, supernode string) ([]*types.Action, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActionsBySuperNode", ctx, supernode) ret0, _ := ret[0].([]*types.Action) @@ -1278,7 +1277,7 @@ func (mr *MockActionKeeperMockRecorder) GetActionsBySuperNode(ctx, supernode any } // IterateActions mocks base method. -func (m *MockActionKeeper) IterateActions(ctx types2.Context, handler func(*types.Action) bool) error { +func (m *MockActionKeeper) IterateActions(ctx types1.Context, handler func(*types.Action) bool) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IterateActions", ctx, handler) ret0, _ := ret[0].(error) @@ -1292,7 +1291,7 @@ func (mr *MockActionKeeperMockRecorder) IterateActions(ctx, handler any) *gomock } // SetAction mocks base method. -func (m *MockActionKeeper) SetAction(ctx types2.Context, action *types.Action) error { +func (m *MockActionKeeper) SetAction(ctx types1.Context, action *types.Action) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetAction", ctx, action) ret0, _ := ret[0].(error) @@ -1304,71 +1303,3 @@ func (mr *MockActionKeeperMockRecorder) SetAction(ctx, action any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAction", reflect.TypeOf((*MockActionKeeper)(nil).SetAction), ctx, action) } - -// MockClaimKeeper is a mock of ClaimKeeper interface. -type MockClaimKeeper struct { - ctrl *gomock.Controller - recorder *MockClaimKeeperMockRecorder - isgomock struct{} -} - -// MockClaimKeeperMockRecorder is the mock recorder for MockClaimKeeper. -type MockClaimKeeperMockRecorder struct { - mock *MockClaimKeeper -} - -// NewMockClaimKeeper creates a new mock instance. -func NewMockClaimKeeper(ctrl *gomock.Controller) *MockClaimKeeper { - mock := &MockClaimKeeper{ctrl: ctrl} - mock.recorder = &MockClaimKeeperMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockClaimKeeper) EXPECT() *MockClaimKeeperMockRecorder { - return m.recorder -} - -// GetClaimRecord mocks base method. -func (m *MockClaimKeeper) GetClaimRecord(ctx types2.Context, arg1 string) (types0.ClaimRecord, bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetClaimRecord", ctx, arg1) - ret0, _ := ret[0].(types0.ClaimRecord) - ret1, _ := ret[1].(bool) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetClaimRecord indicates an expected call of GetClaimRecord. -func (mr *MockClaimKeeperMockRecorder) GetClaimRecord(ctx, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClaimRecord", reflect.TypeOf((*MockClaimKeeper)(nil).GetClaimRecord), ctx, arg1) -} - -// IterateClaimRecords mocks base method. -func (m *MockClaimKeeper) IterateClaimRecords(ctx types2.Context, cb func(types0.ClaimRecord) (bool, error)) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IterateClaimRecords", ctx, cb) - ret0, _ := ret[0].(error) - return ret0 -} - -// IterateClaimRecords indicates an expected call of IterateClaimRecords. -func (mr *MockClaimKeeperMockRecorder) IterateClaimRecords(ctx, cb any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateClaimRecords", reflect.TypeOf((*MockClaimKeeper)(nil).IterateClaimRecords), ctx, cb) -} - -// SetClaimRecord mocks base method. -func (m *MockClaimKeeper) SetClaimRecord(ctx types2.Context, claimRecord types0.ClaimRecord) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetClaimRecord", ctx, claimRecord) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetClaimRecord indicates an expected call of SetClaimRecord. -func (mr *MockClaimKeeperMockRecorder) SetClaimRecord(ctx, claimRecord any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetClaimRecord", reflect.TypeOf((*MockClaimKeeper)(nil).SetClaimRecord), ctx, claimRecord) -} diff --git a/x/evmigration/module/depinject.go b/x/evmigration/module/depinject.go index 947e114b..6be40d33 100644 --- a/x/evmigration/module/depinject.go +++ b/x/evmigration/module/depinject.go @@ -16,7 +16,6 @@ import ( stakingkeeper "github.com/cosmos/cosmos-sdk/x/staking/keeper" actionkeeper "github.com/LumeraProtocol/lumera/x/action/v1/keeper" - claimkeeper "github.com/LumeraProtocol/lumera/x/claim/keeper" "github.com/LumeraProtocol/lumera/x/evmigration/keeper" "github.com/LumeraProtocol/lumera/x/evmigration/types" sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" @@ -52,7 +51,6 @@ type ModuleInputs struct { FeegrantKeeper feegrantkeeper.Keeper SupernodeKeeper sntypes.SupernodeKeeper ActionKeeper actionkeeper.Keeper - ClaimKeeper claimkeeper.Keeper } type ModuleOutputs struct { @@ -81,7 +79,6 @@ func ProvideModule(in ModuleInputs) ModuleOutputs { in.FeegrantKeeper, in.SupernodeKeeper, &in.ActionKeeper, - &in.ClaimKeeper, ) m := NewAppModule(in.Cdc, k) diff --git a/x/evmigration/types/errors.go b/x/evmigration/types/errors.go index d0c505eb..b25fbb36 100644 --- a/x/evmigration/types/errors.go +++ b/x/evmigration/types/errors.go @@ -22,7 +22,7 @@ var ( ErrInvalidMigrationSignature = errors.Register(ModuleName, 1112, "migration signature verification failed") ErrNotValidator = errors.Register(ModuleName, 1113, "legacy address is not a validator operator") - ErrValidatorUnbonding = errors.Register(ModuleName, 1114, "validator is unbonding or unbonded; wait for completion") + ErrValidatorUnbonding = errors.Register(ModuleName, 1114, "validator is unbonding; wait for the unbonding period to complete, then migrate") ErrTooManyDelegators = errors.Register(ModuleName, 1115, "validator has too many delegators; exceeds max_validator_delegations") // Migration to a destination account of an unsupported type (vesting, diff --git a/x/evmigration/types/expected_keepers.go b/x/evmigration/types/expected_keepers.go index bc9af9c1..2632355e 100644 --- a/x/evmigration/types/expected_keepers.go +++ b/x/evmigration/types/expected_keepers.go @@ -14,7 +14,6 @@ import ( stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" - claimtypes "github.com/LumeraProtocol/lumera/x/claim/types" sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" ) @@ -138,10 +137,3 @@ type ActionKeeper interface { SetAction(ctx sdk.Context, action *actiontypes.Action) error GetActionByID(ctx sdk.Context, actionID string) (*actiontypes.Action, bool) } - -// ClaimKeeper defines the expected interface for the x/claim module. -type ClaimKeeper interface { - GetClaimRecord(ctx sdk.Context, address string) (val claimtypes.ClaimRecord, found bool, err error) - SetClaimRecord(ctx sdk.Context, claimRecord claimtypes.ClaimRecord) error - IterateClaimRecords(ctx sdk.Context, cb func(claimtypes.ClaimRecord) (stop bool, err error)) error -} From 768cd94f56a49233f7c5c2e38b0bd6cc8b24fe75 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 15:31:18 -0400 Subject: [PATCH 17/28] test(evmigration): add validator-migration scaling test + benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a realistic testnet-scale scenario for MsgMigrateValidator (~6000 delegation/unbonding/redelegation records in the observed val1 mix, seeded via the real StakingKeeper): - TestMigrateValidatorAtScale: pipeline correctness gate (~1.8s) asserting full re-keying at scale; guards the PR #184 hot-path optimizations against scale-only regressions. No timing assertion, so it is deterministic and does not flake in CI. - BenchmarkMigrateValidatorScaling: on-demand curve (1000/2000/4000/6000) measuring how cost scales; excluded from the pipeline by construction (go test runs no Benchmark* without -bench). Confirms linear ~38us/record scaling. Seeding advances block time per unbonding/redelegation record so their completion times land in distinct staking-queue buckets, matching a real chain. Without this, all records share one completion-time bucket and the migration's InsertUBDQueue / InsertRedelegationQueue re-serialize the whole bucket per entry (O(N^2)) — a seeding artifact that would mask the true linear per-record cost. Widens four shared sign/msg test helpers from *testing.T to testing.TB so both the Test and the Benchmark reuse them. Documents both in the EVM test index. Co-Authored-By: Claude Opus 4.7 --- docs/evm-integration/testing/tests.md | 4 +- .../testing/tests/integration-evmigration.md | 24 ++ .../evmigration/migration_scaling_test.go | 348 ++++++++++++++++++ .../integration/evmigration/migration_test.go | 8 +- 4 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 tests/integration/evmigration/migration_scaling_test.go diff --git a/docs/evm-integration/testing/tests.md b/docs/evm-integration/testing/tests.md index 2b64e0ed..e6cda10a 100644 --- a/docs/evm-integration/testing/tests.md +++ b/docs/evm-integration/testing/tests.md @@ -65,7 +65,7 @@ All three previously identified critical test gaps (mempool capacity pressure, b | **Integration** | Precisebank | 6 | High — [details](tests/integration-precisebank.md) | | **Integration** | Precompiles (standard + custom + wasm) | 42 | High — [details](tests/integration-precompiles.md) | | **Integration** | VM queries / state | 12 | High — [details](tests/integration-vm.md) | -| **Integration** | EVMigration | 14 core + 4 mempool broadcast regressions | High — [details](tests/integration-evmigration.md) | +| **Integration** | EVMigration | 15 core + 4 mempool broadcast regressions + 1 on-demand benchmark | High — [details](tests/integration-evmigration.md) | | | | | | | **Devnet** | EVM / fee market / cross-peer / IBC | 12+ | High — [details](tests/devnet.md) | | **Devnet** | EVMigration tool | 7 modes | High — [details](tests/devnet.md#evm-migration-devnet-tests) | @@ -132,7 +132,7 @@ Each area has its own detailed file with per-test descriptions: | Precisebank | [integration-precisebank.md](tests/integration-precisebank.md) | 6 | | Precompiles (standard + custom + wasm + crossruntime) | [integration-precompiles.md](tests/integration-precompiles.md) | 42 | | VM queries / state | [integration-vm.md](tests/integration-vm.md) | 12 | -| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 14 core + 4 mempool broadcast regressions | +| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 15 core (incl. `TestMigrateValidatorAtScale`, ~6000-record scale gate) + 4 mempool broadcast regressions + 1 on-demand scaling benchmark (`BenchmarkMigrateValidatorScaling`, excluded from pipeline) | ### Devnet Tests diff --git a/docs/evm-integration/testing/tests/integration-evmigration.md b/docs/evm-integration/testing/tests/integration-evmigration.md index aa325d0f..1c9f933f 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -23,9 +23,33 @@ Additional real-node broadcast coverage for zero-signer `submit-proof` txs lives | `TestMigrateValidator_NotValidator` | Rejection when legacy address is not a validator operator with real staking state. | | `TestMigrateValidator_JailedValidator` | Rejection when validator is jailed with real staking/auth state; asserts no migration record or destination validator is created. | | `TestMigrateValidator_UnbondedNotJailedSucceeds` | Recovery path: an Unbonded, non-jailed validator (fell out of the active set on stake weight) migrates successfully with full re-keying of the validator record, delegations, distribution, and counters. | +| `TestMigrateValidatorAtScale` | Scale correctness gate (`migration_scaling_test.go`): migrates a validator with ~6000 records in the realistic testnet val1 mix (~0.42 deleg / 0.19 unbond / 0.39 redel) and asserts every delegation, unbonding delegation, and source-role redelegation is re-keyed. Guards the PR #184 hot-path optimizations against scale-only regressions; deterministic, no wall-clock assertion. See the scaling benchmark below for on-demand measurement. | | `TestQueryMigrationRecord_Integration` | Query server returns record after real migration, nil before. | | `TestQueryMigrationEstimate_Integration` | Estimate query with real staking state reports correct values. | | `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Mempool-suite regression: valid zero-signer migration tx passes real-node CheckTx with app-side mempool enabled. | | `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte` | Mempool-suite negative test: proof-valid zero-signer migration tx is rejected by ante state admission when the legacy account does not exist. | | `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic` | Mempool-suite negative test: malformed `legacy_address` is rejected by `ValidateBasic` in the ante chain on the real-node broadcast path (before mempool admission). | | `TestZeroSignerNonMigrationBroadcastSyncStillRejected` | Mempool-suite negative control: zero-signer non-migration tx remains rejected. | + +## Scaling test + benchmark + +File: `tests/integration/evmigration/migration_scaling_test.go` + +This file carries two things that share one seeding harness: + +- **`TestMigrateValidatorAtScale`** (runs in the pipeline) — a plain `Test` that migrates a ~6000-record validator and asserts full re-keying. This is the CI regression gate for scale correctness (listed in the table above). It makes no wall-clock/timing assertion, so it is deterministic and does not flake. +- **`BenchmarkMigrateValidatorScaling`** (on demand) — measures how the cost *scales* across sizes. + +`BenchmarkMigrateValidatorScaling` measures how `MsgMigrateValidator` scales with the migrating validator's own footprint — the number of delegation / unbonding-delegation / redelegation records the hot path re-keys. It reproduces the live testnet scenario (a validator with thousands of records) that motivated the scoped-iteration / double-fetch / O(1)-refcount optimizations in PR #184, seeding a realistic mix (~0.42 deleg / 0.19 unbond / 0.39 redel, the observed testnet val1 ratio) via the real `StakingKeeper` and timing only the migration call. + +The **benchmark** is **excluded from the normal pipeline by construction**: `go test` runs no `Benchmark*` without an explicit `-bench`, and `make integration-tests` passes none. Run it on demand (pin one measured iteration per size, since seeding thousands of records is slow): + +```bash +go test -tags='integration test' ./tests/integration/evmigration/ \ + -run='^$' -bench='^BenchmarkMigrateValidatorScaling$' -benchtime=1x -timeout=30m -v +``` + +Notes: +- Sizes swept: 1000 / 2000 / 4000 / 6000 records. The reported `records/op` custom metric is the deterministic re-keyed total; wall-clock `ns/op` is indicative only (the in-process harness has no gas meter or consensus). +- Seeding advances block time per unbonding/redelegation record so their completion times spread across distinct staking-queue buckets, mirroring a real chain. Without this, all records share one completion-time bucket and the migration's `InsertUBDQueue` / `InsertRedelegationQueue` re-serialize the whole bucket per entry (O(N²)) — a seeding artifact that masks the true linear per-record cost. +- Each iteration asserts full re-keying (`assertMigrated`): every delegation, unbonding delegation, and source-role redelegation moves off the old valoper onto the new one, and the migration record is stored. diff --git a/tests/integration/evmigration/migration_scaling_test.go b/tests/integration/evmigration/migration_scaling_test.go new file mode 100644 index 00000000..a780fcd0 --- /dev/null +++ b/tests/integration/evmigration/migration_scaling_test.go @@ -0,0 +1,348 @@ +package integration_test + +import ( + "fmt" + "os" + "testing" + "time" + + sdkmath "cosmossdk.io/math" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + distrtypes "github.com/cosmos/cosmos-sdk/x/distribution/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/stretchr/testify/require" + + "github.com/LumeraProtocol/lumera/app" + evmigrationkeeper "github.com/LumeraProtocol/lumera/x/evmigration/keeper" + "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +// BenchmarkMigrateValidatorScaling measures how MsgMigrateValidator scales with a +// validator's own footprint — the number of delegation / unbonding-delegation / +// redelegation records re-keyed by the migration hot path. +// +// This mirrors the live testnet scenario (a validator with thousands of records) +// that motivated the scoped-iteration / double-fetch / O(1)-refcount optimizations +// in PR #184. It seeds a realistic mix (delegations dominate, plus a fraction of +// unbonding delegations and redelegations, ~ the testnet val1 ratio) via the real +// StakingKeeper, then times the migration itself with per-iteration setup excluded. +// +// The BENCHMARK itself is excluded from the normal pipeline by construction: +// `go test` does not run Benchmark* functions without an explicit -bench flag, and +// `make integration-tests` passes no -bench. Run it on demand (seeding thousands of +// delegations is slow, so pin one measured iteration per size with -benchtime=1x): +// +// go test -tags='integration test' ./tests/integration/evmigration/ \ +// -run='^$' -bench='^BenchmarkMigrateValidatorScaling$' -benchtime=1x -timeout=30m -v +// +// The reported "records/op" custom metric is the deterministic total (delegations + +// unbonding + redelegations) re-keyed, which — unlike wall-clock ns/op in this +// gas-meterless in-process harness — is the load-bearing scaling signal. +// +// Scale CORRECTNESS is separately guarded in the pipeline by +// TestMigrateValidatorAtScale (a plain Test that runs one realistic-scale +// migration and asserts full re-keying); it shares this file's harness. +func BenchmarkMigrateValidatorScaling(b *testing.B) { + for _, total := range []int{1000, 2000, 4000, 6000} { + b.Run(fmt.Sprintf("records=%d", total), func(b *testing.B) { + mix := newValidatorRecordMix(total) + for range b.N { + b.StopTimer() + h := newValScaleHarness(b) + legacyPriv, legacyAddr, oldValAddr := h.seedValidator(b, mix) + newPriv, newAddr := createNewEVMAddress(b) + newValAddr := sdk.ValAddress(newAddr) + msg := newValidatorMsg(b, legacyPriv, legacyAddr, newPriv, newAddr) + b.StartTimer() + + _, err := h.msgServer.MigrateValidator(h.ctx, msg) + + b.StopTimer() + require.NoError(b, err, "migration must succeed at scale (records=%d)", total) + h.assertMigrated(b, oldValAddr, newValAddr, mix) + b.ReportMetric(float64(mix.total()), "records/op") + } + }) + } +} + +// TestMigrateValidatorAtScale is a pipeline correctness gate: it migrates a +// validator carrying a realistic testnet-scale footprint (~6000 delegation / +// unbonding / redelegation records in the observed val1 mix) and asserts every +// record is re-keyed onto the new operator address. It guards the scoped-iteration +// / double-fetch / O(1)-refcount hot-path optimizations (PR #184) against +// regressions that only surface at scale — e.g. a reintroduced full-chain scan or +// a pre-check bound that rejects large validators. +// +// Scaling MEASUREMENT (the ns/op curve across sizes) lives in the on-demand +// BenchmarkMigrateValidatorScaling. This test asserts only correctness at scale, +// which is deterministic and CI-stable — it makes no wall-clock assertion, so it +// never flakes on a slow or loaded runner. +func TestMigrateValidatorAtScale(t *testing.T) { + const records = 6000 + mix := newValidatorRecordMix(records) + + h := newValScaleHarness(t) + legacyPriv, legacyAddr, oldValAddr := h.seedValidator(t, mix) + newPriv, newAddr := createNewEVMAddress(t) + newValAddr := sdk.ValAddress(newAddr) + msg := newValidatorMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + + _, err := h.msgServer.MigrateValidator(h.ctx, msg) + require.NoError(t, err, "validator migration must succeed at scale (records=%d)", records) + + h.assertMigrated(t, oldValAddr, newValAddr, mix) +} + +// validatorRecordMix splits a target record total into delegation / unbonding / +// redelegation counts approximating the observed testnet val1 ratio +// (2188 deleg + 979 unbond + 1982 redel ≈ 5149 → ~0.42 / 0.19 / 0.39). The self +// delegation is counted as one of the delegation records. +type validatorRecordMix struct { + delegations int + unbonding int + redelegations int +} + +func newValidatorRecordMix(total int) validatorRecordMix { + if total < 3 { + total = 3 + } + unbonding := total * 19 / 100 + redelegations := total * 39 / 100 + delegations := total - unbonding - redelegations // remainder, includes the self delegation + return validatorRecordMix{ + delegations: delegations, + unbonding: unbonding, + redelegations: redelegations, + } +} + +func (m validatorRecordMix) total() int { + return m.delegations + m.unbonding + m.redelegations +} + +type valScaleHarness struct { + app *app.App + ctx sdk.Context + keeper evmigrationkeeper.Keeper + msgServer types.MsgServer +} + +func newValScaleHarness(tb testing.TB) *valScaleHarness { + tb.Helper() + os.Setenv("SYSTEM_TESTS", "true") + + a := app.Setup(tb) + ctx := a.BaseApp.NewContext(true). + WithChainID(integrationTestChainID). + WithBlockTime(time.Now().UTC()) + k := a.EvmigrationKeeper + h := &valScaleHarness{ + app: a, + ctx: ctx, + keeper: k, + msgServer: evmigrationkeeper.NewMsgServerImpl(k), + } + // High MaxValidatorDelegations so large seed counts are not rejected by the + // pre-check bound; the whole point of this benchmark is to exercise scale. + require.NoError(tb, k.Params.Set(ctx, types.NewParams(true, 0, 50, 1_000_000, 20))) + return h +} + +// fundedAccount creates a registered secp256k1 account funded with coins. +func (h *valScaleHarness) fundedAccount(tb testing.TB, ulume int64) sdk.AccAddress { + tb.Helper() + priv := secp256k1.GenPrivKey() + addr := sdk.AccAddress(priv.PubKey().Address()) + acc := h.app.AuthKeeper.NewAccountWithAddress(h.ctx, addr) + baseAcc, ok := acc.(*authtypes.BaseAccount) + require.True(tb, ok) + require.NoError(tb, baseAcc.SetPubKey(priv.PubKey())) + h.app.AuthKeeper.SetAccount(h.ctx, baseAcc) + + coins := sdk.NewCoins(sdk.NewInt64Coin("ulume", ulume)) + require.NoError(tb, h.app.BankKeeper.MintCoins(h.ctx, "mint", coins)) + require.NoError(tb, h.app.BankKeeper.SendCoinsFromModuleToAccount(h.ctx, "mint", addr, coins)) + return addr +} + +// createUnbondedValidator sets up an Unbonded validator with initialized +// distribution state and a self delegation, returning the operator legacy key. +func (h *valScaleHarness) createUnbondedValidator(tb testing.TB, selfBond sdkmath.Int) (*secp256k1.PrivKey, sdk.AccAddress, sdk.ValAddress) { + tb.Helper() + legacyPriv := secp256k1.GenPrivKey() + legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address()) + + acc := h.app.AuthKeeper.NewAccountWithAddress(h.ctx, legacyAddr) + baseAcc, ok := acc.(*authtypes.BaseAccount) + require.True(tb, ok) + require.NoError(tb, baseAcc.SetPubKey(legacyPriv.PubKey())) + h.app.AuthKeeper.SetAccount(h.ctx, baseAcc) + opCoins := sdk.NewCoins(sdk.NewInt64Coin("ulume", selfBond.Int64()*2)) + require.NoError(tb, h.app.BankKeeper.MintCoins(h.ctx, "mint", opCoins)) + require.NoError(tb, h.app.BankKeeper.SendCoinsFromModuleToAccount(h.ctx, "mint", legacyAddr, opCoins)) + + valAddr := sdk.ValAddress(legacyAddr) + consPub := ed25519.GenPrivKey().PubKey() + pkAny, err := codectypes.NewAnyWithValue(consPub) + require.NoError(tb, err) + + val := stakingtypes.Validator{ + OperatorAddress: valAddr.String(), + ConsensusPubkey: pkAny, + Jailed: false, + Status: stakingtypes.Unbonded, + Tokens: sdkmath.ZeroInt(), + DelegatorShares: sdkmath.LegacyZeroDec(), + Description: stakingtypes.Description{Moniker: "scale-validator"}, + Commission: stakingtypes.NewCommission( + sdkmath.LegacyNewDecWithPrec(1, 1), + sdkmath.LegacyNewDecWithPrec(2, 1), + sdkmath.LegacyNewDecWithPrec(1, 2), + ), + MinSelfDelegation: sdkmath.OneInt(), + } + require.NoError(tb, h.app.StakingKeeper.SetValidator(h.ctx, val)) + require.NoError(tb, h.app.StakingKeeper.SetValidatorByConsAddr(h.ctx, val)) + require.NoError(tb, h.app.StakingKeeper.SetNewValidatorByPowerIndex(h.ctx, val)) + + require.NoError(tb, h.app.DistrKeeper.SetValidatorHistoricalRewards(h.ctx, valAddr, 0, + distrtypes.NewValidatorHistoricalRewards(sdk.DecCoins{}, 1))) + require.NoError(tb, h.app.DistrKeeper.SetValidatorCurrentRewards(h.ctx, valAddr, + distrtypes.NewValidatorCurrentRewards(sdk.DecCoins{}, 1))) + require.NoError(tb, h.app.DistrKeeper.SetValidatorAccumulatedCommission(h.ctx, valAddr, + distrtypes.InitialValidatorAccumulatedCommission())) + require.NoError(tb, h.app.DistrKeeper.SetValidatorOutstandingRewards(h.ctx, valAddr, + distrtypes.ValidatorOutstandingRewards{Rewards: sdk.DecCoins{}})) + + val, err = h.app.StakingKeeper.GetValidator(h.ctx, valAddr) + require.NoError(tb, err) + _, err = h.app.StakingKeeper.Delegate(h.ctx, legacyAddr, selfBond, stakingtypes.Unbonded, val, true) + require.NoError(tb, err) + + return legacyPriv, legacyAddr, valAddr +} + +// delegate creates a fresh funded delegator and delegates to valAddr, returning +// the delegator's address. +func (h *valScaleHarness) delegate(tb testing.TB, valAddr sdk.ValAddress, amount sdkmath.Int) sdk.AccAddress { + tb.Helper() + delAddr := h.fundedAccount(tb, amount.Int64()*2) + val, err := h.app.StakingKeeper.GetValidator(h.ctx, valAddr) + require.NoError(tb, err) + _, err = h.app.StakingKeeper.Delegate(h.ctx, delAddr, amount, stakingtypes.Unbonded, val, true) + require.NoError(tb, err) + return delAddr +} + +// seedValidator builds a bonded validator whose re-keyable footprint matches mix: +// mix.delegations delegations (including the self delegation), mix.unbonding +// unbonding delegations, and mix.redelegations redelegations with the target as +// the redelegation SOURCE. Returns the operator legacy key and addresses. +func (h *valScaleHarness) seedValidator(tb testing.TB, mix validatorRecordMix) (*secp256k1.PrivKey, sdk.AccAddress, sdk.ValAddress) { + tb.Helper() + selfBond := sdkmath.NewInt(1_000_000) + legacyPriv, legacyAddr, valAddr := h.createUnbondedValidator(tb, selfBond) + h.bond(tb, valAddr) + + delAmt := sdkmath.NewInt(100_000) + + // Plain delegations (mix.delegations - 1 external; the self delegation is the + // remaining one). + for range mix.delegations - 1 { + h.delegate(tb, valAddr, delAmt) + } + + // Unbonding delegations: delegate then fully undelegate, leaving only the + // unbonding-delegation record (no residual delegation to this validator). + // advanceBlockTime spreads their completion times (see helper). + for range mix.unbonding { + h.advanceBlockTime() + delAddr := h.delegate(tb, valAddr, delAmt) + del, err := h.app.StakingKeeper.GetDelegation(h.ctx, delAddr, valAddr) + require.NoError(tb, err) + _, _, err = h.app.StakingKeeper.Undelegate(h.ctx, delAddr, valAddr, del.Shares) + require.NoError(tb, err) + } + + // Redelegations: delegate to the target, then redelegate target -> sink, + // leaving a redelegation record with the target as the SOURCE validator + // (and no residual delegation to the target). + if mix.redelegations > 0 { + _, _, sinkValAddr := h.createUnbondedValidator(tb, selfBond) + h.bond(tb, sinkValAddr) + for range mix.redelegations { + h.advanceBlockTime() + delAddr := h.delegate(tb, valAddr, delAmt) + del, err := h.app.StakingKeeper.GetDelegation(h.ctx, delAddr, valAddr) + require.NoError(tb, err) + _, err = h.app.StakingKeeper.BeginRedelegation(h.ctx, delAddr, valAddr, sinkValAddr, del.Shares) + require.NoError(tb, err) + } + } + + return legacyPriv, legacyAddr, valAddr +} + +// advanceBlockTime moves the harness block time forward one second so that +// unbonding / redelegation records seeded on successive iterations receive +// DISTINCT completion times, landing in separate staking-queue buckets. +// +// This is load-bearing for realism, not cosmetics: the unbonding and +// redelegation queues are keyed by completion time, and each key holds a slice +// of all entries maturing at that instant. Seeding every record in one block +// gives them one shared completion time, piling all N entries into a single +// bucket; the migration then re-inserts them via InsertUBDQueue / +// InsertRedelegationQueue, each a read-append-write of the whole bucket — O(N²) +// serialization that a CPU profile pins on DVVTriplets marshal/unmarshal. A real +// validator accrues these records across thousands of blocks, so completion +// times are spread and buckets stay tiny; advancing time here reproduces that +// and lets the benchmark measure the true (linear) per-record migration cost. +func (h *valScaleHarness) advanceBlockTime() { + h.ctx = h.ctx.WithBlockTime(h.ctx.BlockTime().Add(time.Second)) +} + +// bond promotes a validator to Bonded status and records its power. +func (h *valScaleHarness) bond(tb testing.TB, valAddr sdk.ValAddress) { + tb.Helper() + val, err := h.app.StakingKeeper.GetValidator(h.ctx, valAddr) + require.NoError(tb, err) + val.Status = stakingtypes.Bonded + require.NoError(tb, h.app.StakingKeeper.SetValidator(h.ctx, val)) + require.NoError(tb, h.app.StakingKeeper.SetLastValidatorPower(h.ctx, valAddr, val.Tokens.Int64())) +} + +// assertMigrated verifies the migration re-keyed every record type off the old +// operator address and onto the new one. +func (h *valScaleHarness) assertMigrated(tb testing.TB, oldValAddr, newValAddr sdk.ValAddress, mix validatorRecordMix) { + tb.Helper() + + newDels, err := h.app.StakingKeeper.GetValidatorDelegations(h.ctx, newValAddr) + require.NoError(tb, err) + require.Len(tb, newDels, mix.delegations, "all delegations re-keyed to new valoper") + + oldDels, err := h.app.StakingKeeper.GetValidatorDelegations(h.ctx, oldValAddr) + require.NoError(tb, err) + require.Empty(tb, oldDels, "no delegations remain under old valoper") + + newUbds, err := h.app.StakingKeeper.GetUnbondingDelegationsFromValidator(h.ctx, newValAddr) + require.NoError(tb, err) + require.Len(tb, newUbds, mix.unbonding, "all unbonding delegations re-keyed") + + newReds, err := h.app.StakingKeeper.GetRedelegationsFromSrcValidator(h.ctx, newValAddr) + require.NoError(tb, err) + require.Len(tb, newReds, mix.redelegations, "all source-role redelegations re-keyed") + + // The migration record is keyed by the operator's account-address string + // (sdk.AccAddress(valoper)); its presence confirms the migration finalized. + legacyAcc := sdk.AccAddress(oldValAddr) + rec, err := h.keeper.MigrationRecords.Get(h.ctx, legacyAcc.String()) + require.NoError(tb, err, "migration record stored for the operator") + require.Equal(tb, legacyAcc.String(), rec.LegacyAddress) +} diff --git a/tests/integration/evmigration/migration_test.go b/tests/integration/evmigration/migration_test.go index d73b69ed..89183cd3 100644 --- a/tests/integration/evmigration/migration_test.go +++ b/tests/integration/evmigration/migration_test.go @@ -83,7 +83,7 @@ func signMigration(t *testing.T, privKey *secp256k1.PrivKey, legacyAddr, newAddr return sig } -func signValidatorMigration(t *testing.T, privKey *secp256k1.PrivKey, legacyAddr, newAddr sdk.AccAddress) []byte { +func signValidatorMigration(t testing.TB, privKey *secp256k1.PrivKey, legacyAddr, newAddr sdk.AccAddress) []byte { t.Helper() msg := fmt.Sprintf("lumera-evm-migration:%s:%d:validator:%s:%s", integrationTestChainID, lcfg.EVMChainID, legacyAddr.String(), newAddr.String()) hash := sha256.Sum256([]byte(msg)) @@ -92,7 +92,7 @@ func signValidatorMigration(t *testing.T, privKey *secp256k1.PrivKey, legacyAddr return sig } -func signNewMigration(t *testing.T, kind string, privKey *evmcryptotypes.PrivKey, legacyAddr, newAddr sdk.AccAddress) []byte { +func signNewMigration(t testing.TB, kind string, privKey *evmcryptotypes.PrivKey, legacyAddr, newAddr sdk.AccAddress) []byte { t.Helper() msg := fmt.Sprintf("lumera-evm-migration:%s:%d:%s:%s:%s", integrationTestChainID, lcfg.EVMChainID, kind, legacyAddr.String(), newAddr.String()) sig, err := privKey.Sign([]byte(msg)) @@ -100,7 +100,7 @@ func signNewMigration(t *testing.T, kind string, privKey *evmcryptotypes.PrivKey return sig } -func createNewEVMAddress(t *testing.T) (*evmcryptotypes.PrivKey, sdk.AccAddress) { +func createNewEVMAddress(t testing.TB) (*evmcryptotypes.PrivKey, sdk.AccAddress) { t.Helper() privKey, err := evmcryptotypes.GenerateKey() require.NoError(t, err) @@ -125,7 +125,7 @@ func newClaimMsg(t *testing.T, legacyPrivKey *secp256k1.PrivKey, legacyAddr sdk. } } -func newValidatorMsg(t *testing.T, legacyPrivKey *secp256k1.PrivKey, legacyAddr sdk.AccAddress, newPrivKey *evmcryptotypes.PrivKey, newAddr sdk.AccAddress) *types.MsgMigrateValidator { +func newValidatorMsg(t testing.TB, legacyPrivKey *secp256k1.PrivKey, legacyAddr sdk.AccAddress, newPrivKey *evmcryptotypes.PrivKey, newAddr sdk.AccAddress) *types.MsgMigrateValidator { t.Helper() return &types.MsgMigrateValidator{ LegacyAddress: legacyAddr.String(), From 794af6ec2c6e6a78c18dcea0451be7737b42eb22 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 15:33:54 -0400 Subject: [PATCH 18/28] docs: add v1.20.1 changelog entry Release notes for the migration-only hotfix hardening x/evmigration for large validators and live-network conditions: validator/account migration performance (scoped index reads, single-write refcount, secondary-index action re-keying), correctness & safety fixes (tokens-not-shares stake, Unbonded-validator recovery, claim-record removal, deterministic redelegation replay), and the v1.20.1 upgrade handler. No store changes. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b192781..80341573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ --- +## 1.20.1 + +Changes included since `v1.20.0` (range: `v1.20.0..v1.20.1`). + +A migration-only hotfix that hardens `x/evmigration` for large validators and live-network conditions. No store changes. + +### Account migration performance (`x/evmigration`) + +- Optimized validator migration so its cost scales with the migrating validator's own footprint instead of total chain state: scoped distribution/staking index reads replace full-chain `Iterate*` scans, a redundant second read of the validator's staking records is removed, and the historical-rewards reference count is now written once (`base + N`) instead of via N+1 full-chain scans. In the modeled large-validator case this touches ~215× fewer KV keys (1507 → 7). +- Optimized action re-keying (both `ClaimLegacyAccount` and `MigrateValidator`) to resolve affected actions through the `Creator`/`SuperNodes` secondary indexes instead of scanning the entire action store. This fixes `ClaimLegacyAccount` txs observed burning ~1.5B gas each on testnet, which would be unminable under a mainnet block gas cap. + +### Account migration correctness & safety (`x/evmigration`) + +- Fixed a fund-locking bug: migration rebuilt `DelegatorStartingInfo.Stake` from raw delegation shares instead of tokens, so any delegation to an ever-slashed validator would panic the delegator's next reward withdrawal, undelegate, or redelegate (`calculated final stake … greater than current stake`). Stake is now stored as tokens-from-shares, matching the SDK invariant. +- Fixed a validator-migration deadlock: `Unbonded` (non-jailed) validators — those that fell out of the active set purely on stake weight — can now migrate and recover their keys, funds, and rewards. Only `Unbonding` validators remain blocked, because their live unbonding-queue entry would otherwise be orphaned and halt the chain at maturity; the guidance is to wait for the unbonding period to complete. +- Removed claim-record re-keying from migration. The claim DB is frozen (claiming ended 2025-01-01) and retained for reference only; re-keying was cosmetic and scanned the ~18K-record store on every migration. The legacy→new mapping in `MigrationRecords` can reconstruct claim linkage offline if ever needed. +- Made scoped redelegation replay deterministic (sorted store-key order) so migrations produce identical app hashes across nodes. +- Hardened `MigrationEstimate` to fail loud on lookup errors instead of returning partial data. + +### Upgrade & operations + +- Added and registered the `v1.20.1` upgrade handler as a migration-only hotfix (standard handler, no store changes). +- Updated the Lumera uploader devnet default gRPC port to `15051` to avoid the reserved Windows/Docker Desktop `50051` range, and documented chain-helper network defaults for migration tooling. + +--- + ## 1.20.0 Changes included since `v1.11.1` (range: `v1.11.1..v1.20.0`). From 6e6a5a4a7c33814a4dcc3a43ea2db7e1bc5c90e5 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 15:42:01 -0400 Subject: [PATCH 19/28] docs(evmigration): present validator-migration scaling benchmark results + analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the measured BenchmarkMigrateValidatorScaling results (1000/2000/4000/6000 records) and analysis to two places: - Test index (integration-evmigration.md): a Representative results table plus a linear-scaling read (~38us/record, slope ~1.0) with the gas-meterless-harness caveat and a cross-link to the gas design doc. - Gas design doc (2026-06-22-validator-migration-gas-design.md): a dated section noting the benchmark independently confirms the linear per-record scaling that the base + per_record*N formula assumes — the live devnet/testnet numbers were single-point and could not distinguish linear from super-linear. Includes the ns/op-vs-gas caveat and the completion-time-bucket O(N^2) worst case. PR #184 description updated with the same table + analysis. Co-Authored-By: Claude Opus 4.7 --- ...26-06-22-validator-migration-gas-design.md | 46 +++++++++++++++++++ .../testing/tests/integration-evmigration.md | 13 ++++++ 2 files changed, 59 insertions(+) diff --git a/docs/design/2026-06-22-validator-migration-gas-design.md b/docs/design/2026-06-22-validator-migration-gas-design.md index b0277a78..11a4e925 100644 --- a/docs/design/2026-06-22-validator-migration-gas-design.md +++ b/docs/design/2026-06-22-validator-migration-gas-design.md @@ -214,3 +214,49 @@ is execution time, not gas. A finite block `max_gas` that still admits a within- migration would need to be ≥ ~11.3B (≈ 450× the 25M code default), so setting one provides no meaningful DoS bound; the `max_validator_delegations` cap remains the real guard, and raising it only lengthens the worst-case stall. + +--- + +## In-process scaling benchmark (2026-07-04) + +`BenchmarkMigrateValidatorScaling` +(`tests/integration/evmigration/migration_scaling_test.go`) is an in-repo, +independent confirmation that `MsgMigrateValidator` scales **linearly** with the +migrating validator's own record count. The devnet (§2026-06-23) and testnet +(§2026-07-03) measurements were essentially single-point — each validator sat at +~5149–5752 records — so they pinned a per-record *rate* but could not by +themselves distinguish linear from super-linear. This benchmark varies N +(1000 → 6000) against a realistic record mix seeded through the real +`StakingKeeper` and shows a flat per-record cost. + +Representative run (`-benchtime=1x`, linux/amd64, AMD Ryzen 9 5900X): + +| records | ns/op | ms/op | µs/record | +| ------: | ----------: | ----: | --------: | +| 1000 | 40,987,018 | 41.0 | 41.0 | +| 2000 | 75,297,933 | 75.3 | 37.6 | +| 4000 | 147,209,620 | 147.2 | 36.8 | +| 6000 | 240,509,053 | 240.5 | 40.1 | + +- **Linear:** per-record cost is flat (~38 µs/record) across a 6× range; each + doubling of N roughly doubles wall time (slope ≈ 1.0). This validates the + `base + per_record × N` formula *shape* used throughout this doc. +- **ns/op ≠ gas:** the harness runs `app.Setup` + `BaseApp.NewContext` with no gas + meter and no consensus, so it measures *algorithmic shape*, not on-chain gas or + block-stall seconds. Use the live devnet/testnet numbers (688k–1.02M gas/record; + ≈30M gas/s) for absolute gas and time; use this benchmark only for the scaling + exponent. +- **Linearity relies on spread completion times.** The unbonding/redelegation + queues are keyed by completion time (one slice per instant). Records that accrue + across many blocks — the real-chain case — land in distinct buckets, so the + migration's per-entry `InsertUBDQueue` / `InsertRedelegationQueue` re-insert is + O(1) each → O(N) total; the benchmark seeds across advancing block times to + reproduce this. A pathological validator whose unbond/redelegation records all + share one completion time (e.g. a mass action in a single block) would instead + force O(N²) re-insertion into a single bucket — a worst case the live + measurements did not hit and that `max_validator_delegations` does not directly + bound. Not a concern for any observed validator, but worth keeping in mind if the + cap is raised substantially. +- **CI gate:** `TestMigrateValidatorAtScale` runs the 6000-record row in the + pipeline (~1.8 s, seeding included) asserting full re-keying; the benchmark + itself is on-demand (`go test` runs no `Benchmark*` without `-bench`). diff --git a/docs/evm-integration/testing/tests/integration-evmigration.md b/docs/evm-integration/testing/tests/integration-evmigration.md index 1c9f933f..55c77bf4 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -49,6 +49,19 @@ go test -tags='integration test' ./tests/integration/evmigration/ \ -run='^$' -bench='^BenchmarkMigrateValidatorScaling$' -benchtime=1x -timeout=30m -v ``` +### Representative results + +Single-iteration run (`-benchtime=1x`, linux/amd64, AMD Ryzen 9 5900X): + +| records | ns/op | ms/op | µs/record | +| ------: | ----------: | ----: | --------: | +| 1000 | 40,987,018 | 41.0 | 41.0 | +| 2000 | 75,297,933 | 75.3 | 37.6 | +| 4000 | 147,209,620 | 147.2 | 36.8 | +| 6000 | 240,509,053 | 240.5 | 40.1 | + +**Analysis:** cost is **linear** in the validator's own record count — per-record cost is flat at ~38 µs/record across a 6× range and each doubling of N roughly doubles the wall time (slope ≈ 1.0). This is `ns/op` in a gas-meterless, consensus-free harness, so it reflects *algorithmic shape* rather than on-chain gas or block time; it corroborates the live devnet finding of linear ~688k gas/record (see [`docs/design/2026-06-22-validator-migration-gas-design.md`](../../../design/2026-06-22-validator-migration-gas-design.md)). The `TestMigrateValidatorAtScale` pipeline gate runs the 6000-record row (~1.8 s wall clock including seeding). + Notes: - Sizes swept: 1000 / 2000 / 4000 / 6000 records. The reported `records/op` custom metric is the deterministic re-keyed total; wall-clock `ns/op` is indicative only (the in-process harness has no gas meter or consensus). - Seeding advances block time per unbonding/redelegation record so their completion times spread across distinct staking-queue buckets, mirroring a real chain. Without this, all records share one completion-time bucket and the migration's `InsertUBDQueue` / `InsertRedelegationQueue` re-serialize the whole bucket per entry (O(N²)) — a seeding artifact that masks the true linear per-record cost. From 9b10a15cad2231a4f7594ca9c6f23ba864d29064 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sat, 4 Jul 2026 23:01:00 -0400 Subject: [PATCH 20/28] fix(upgrades): skip v1.20.0 on mainnet, run EVM bring-up via v1.20.1 Mainnet now skips the v1.20.0 upgrade entirely and performs the EVM store additions + Lumera EVM param finalization through v1.20.1 instead, reusing v1.20.0's StoreUpgrades and handler verbatim so everything v1.20.0 does is guaranteed to run. Testnet and devnet (which already ran v1.20.0) keep it there and treat v1.20.1 as a migration-only hotfix. Mirrors the existing v1.8.0/v1.8.4 mainnet-skip precedent (setup-time params.ChainID gating). Adds a behavioral test that boots the app on a mainnet context and asserts v1.20.1 applies the Lumera EVM params and the migration deadline. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 +- app/upgrades/upgrades.go | 20 ++++- .../upgrades_bringup_external_test.go | 73 +++++++++++++++++++ app/upgrades/upgrades_test.go | 67 ++++++++++++++++- 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 app/upgrades/upgrades_bringup_external_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 80341573..0ccd0097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Changes included since `v1.20.0` (range: `v1.20.0..v1.20.1`). -A migration-only hotfix that hardens `x/evmigration` for large validators and live-network conditions. No store changes. +A hotfix that hardens `x/evmigration` for large validators and live-network conditions. On networks that already ran `v1.20.0` (testnet/devnet) it is migration-only with no store changes; on mainnet — which skips `v1.20.0` entirely — `v1.20.1` also performs the full EVM bring-up (adds the EVM stores and finalizes Lumera EVM params) by reusing the `v1.20.0` store additions and handler. ### Account migration performance (`x/evmigration`) @@ -23,7 +23,7 @@ A migration-only hotfix that hardens `x/evmigration` for large validators and li ### Upgrade & operations -- Added and registered the `v1.20.1` upgrade handler as a migration-only hotfix (standard handler, no store changes). +- Excluded the `v1.20.0` upgrade handler on mainnet and routed the full EVM bring-up through `v1.20.1` instead (mirrors the existing `v1.8.0`/`v1.8.4` mainnet-skip precedent). On mainnet, `v1.20.1` reuses the `v1.20.0` store additions and handler verbatim, so everything `v1.20.0` does is guaranteed to run; on testnet/devnet (which already ran `v1.20.0`) `v1.20.1` remains a migration-only hotfix (standard handler, no store changes). - Updated the Lumera uploader devnet default gRPC port to `15051` to avoid the reserved Windows/Docker Desktop `50051` range, and documented chain-helper network defaults for migration tooling. --- diff --git a/app/upgrades/upgrades.go b/app/upgrades/upgrades.go index e295c019..1298656b 100644 --- a/app/upgrades/upgrades.go +++ b/app/upgrades/upgrades.go @@ -40,8 +40,8 @@ import ( // | v1.11.0 | custom | add audit store | Initializes audit params with dynamic epoch_zero_height // | v1.11.1 | custom | conditional add audit store | Supports direct v1.10.1->v1.11.1 and enforces audit min_disk_free_percent floor (>=15) // | v1.12.0 | custom | none (Everlight in supernode) | Runs migrations; Everlight logic embedded in x/supernode -// | v1.20.0 | custom | add feemarket, precisebank, vm, erc20 | Adds EVM stores and applies Lumera EVM param finalization -// | v1.20.1 | standard | none | Hotfix release; migrations only +// | v1.20.0 | custom | non-mainnet: add feemarket, precisebank, vm, erc20 | EVM bring-up; gated to non-mainnet (mainnet runs it via v1.20.1) +// | v1.20.1 | mixed | mainnet: add feemarket, precisebank, vm, erc20 | Mainnet reuses v1.20.0 store+handler (full EVM bring-up); non-mainnet is migrations only // ================================================================================================================================= type UpgradeConfig struct { @@ -155,11 +155,27 @@ func SetupUpgrades(upgradeName string, params appParams.AppUpgradeParams) (Upgra Handler: upgrade_v1_12_0.CreateUpgradeHandler(params), }, true case upgrade_v1_20_0.UpgradeName: + // Mainnet skips v1.20.0 entirely and runs the EVM bring-up via v1.20.1 + // instead (see the v1.20.1 case). Testnet and devnet already ran v1.20.0, + // so they keep it. Mirrors the v1.8.0/v1.8.4 mainnet-skip precedent. + if IsMainnet(params.ChainID) { + return NoUpgradeConfig, true + } return UpgradeConfig{ StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), }, true case upgradeNameV1201: + // On mainnet v1.20.1 carries the full EVM bring-up that other networks got + // from v1.20.0: it reuses v1.20.0's store additions and handler verbatim, + // so everything v1.20.0 does is guaranteed to run here. On testnet/devnet + // (which already ran v1.20.0) v1.20.1 is a migration-only hotfix. + if IsMainnet(params.ChainID) { + return UpgradeConfig{ + StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, + Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), + }, true + } return UpgradeConfig{ Handler: standardUpgradeHandler(upgradeNameV1201, params), }, true diff --git a/app/upgrades/upgrades_bringup_external_test.go b/app/upgrades/upgrades_bringup_external_test.go new file mode 100644 index 00000000..8a942351 --- /dev/null +++ b/app/upgrades/upgrades_bringup_external_test.go @@ -0,0 +1,73 @@ +package upgrades_test + +import ( + "testing" + + "cosmossdk.io/log" + upgradetypes "cosmossdk.io/x/upgrade/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + erc20types "github.com/cosmos/evm/x/erc20/types" + evmtypes "github.com/cosmos/evm/x/vm/types" + "github.com/stretchr/testify/require" + + lumeraapp "github.com/LumeraProtocol/lumera/app" + appevm "github.com/LumeraProtocol/lumera/app/evm" + "github.com/LumeraProtocol/lumera/app/upgrades" + appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" +) + +// v1.20.1's on-chain name. Defined locally because this external test package +// cannot see the unexported constant in package upgrades. +const upgradeNameV1201 = "v1.20.1" + +// TestV1201MainnetRunsFullEVMBringup exercises the real v1.20.1 handler wiring +// on a mainnet context and proves it performs the same EVM finalization the +// v1.20.0 handler would have: it re-applies the Lumera EVM params (overwriting +// upstream defaults) and sets the 3-calendar-month migration deadline. +// +// This locks the user's requirement — "everything that runs in v1.20.0 must run +// in v1.20.1" on mainnet — at the SetupUpgrades entry point, not just at the +// v1.20.0 handler in isolation. +func TestV1201MainnetRunsFullEVMBringup(t *testing.T) { + app := lumeraapp.Setup(t) + ctx := app.BaseApp.NewContext(false).WithChainID("lumera-mainnet-1") + + // Clobber EVM params to upstream defaults (extended "aatom" denom) so a passing + // assertion proves the handler actually re-applied Lumera's params. + require.NoError(t, app.EVMKeeper.SetParams(ctx, evmtypes.DefaultParams())) + + // A coordinated mainnet upgrade starts the node with its real chain ID, so the + // setup-time params.ChainID that gates store mounting is "lumera-mainnet-1" + // (the same assumption the v1.8.4 mainnet store upgrade relies on). + params := appParams.AppUpgradeParams{ + ChainID: "lumera-mainnet-1", + Logger: log.NewNopLogger(), + ModuleManager: module.NewManager(), + Configurator: module.NewConfigurator(nil, nil, nil), + BankKeeper: app.BankKeeper, + EVMKeeper: app.EVMKeeper, + FeeMarketKeeper: &app.FeeMarketKeeper, + Erc20Keeper: &app.Erc20Keeper, + Erc20StoreKey: app.GetKey(erc20types.StoreKey), + EvmigrationKeeper: &app.EvmigrationKeeper, + } + + config, found := upgrades.SetupUpgrades(upgradeNameV1201, params) + require.True(t, found) + require.NotNil(t, config.Handler, "v1.20.1 must carry a handler on mainnet") + require.NotNil(t, config.StoreUpgrade, "v1.20.1 must mount the EVM stores on mainnet") + + wantEnd := ctx.BlockTime().AddDate(0, 3, 0).Unix() + + _, err := config.Handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + require.NoError(t, err) + + require.Equal(t, appevm.LumeraEVMGenesisState().Params, app.EVMKeeper.GetParams(ctx), + "v1.20.1 on mainnet should apply the Lumera EVM params, overwriting upstream defaults") + + emParams, err := app.EvmigrationKeeper.Params.Get(ctx) + require.NoError(t, err) + require.Equal(t, wantEnd, emParams.MigrationEndTime, + "v1.20.1 on mainnet should set migration_end_time to upgrade block time + 3 months") +} diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index f6629ca9..d251bc1c 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -90,19 +90,28 @@ func TestSetupUpgradesAndHandlers(t *testing.T) { require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.0 should add evm store key") require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.0 should add erc20 store key") } + if upgradeName == upgradeNameV1201 && config.StoreUpgrade != nil { + require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 on mainnet should add feemarket store key") + require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 on mainnet should add precisebank store key") + require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 on mainnet should add evm store key") + require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 on mainnet should add erc20 store key") + } if config.Handler == nil { continue } // Custom upgrades that need keepers are skipped in this lightweight harness. + // On mainnet v1.20.1 reuses the v1.20.0 EVM bring-up handler, which also + // needs keepers; on other networks it is migration-only and safe to run. if upgradeName == upgrade_v1_9_0.UpgradeName || upgradeName == upgrade_v1_10_0.UpgradeName || upgradeName == upgrade_v1_10_1.UpgradeName || upgradeName == upgrade_v1_11_0.UpgradeName || upgradeName == upgrade_v1_11_1.UpgradeName || upgradeName == upgrade_v1_12_0.UpgradeName || - upgradeName == upgrade_v1_20_0.UpgradeName { + upgradeName == upgrade_v1_20_0.UpgradeName || + (upgradeName == upgradeNameV1201 && IsMainnet(tt.chainID)) { continue } @@ -150,6 +159,53 @@ func TestV1201IsRegisteredAsMigrationOnlyUpgrade(t *testing.T) { require.NotNil(t, vm) } +// Mainnet skips v1.20.0 outright: no handler runs and no stores are mounted, so +// a mainnet node never applies the (partial, later-superseded) v1.20.0 bring-up. +func TestV1200ExcludedOnMainnet(t *testing.T) { + params := newTestUpgradeParams("lumera-mainnet-1") + config, found := SetupUpgrades(upgrade_v1_20_0.UpgradeName, params) + require.True(t, found, "v1.20.0 must remain a known upgrade name") + require.Nil(t, config.Handler, "v1.20.0 must not run a handler on mainnet") + require.Nil(t, config.StoreUpgrade, "v1.20.0 must not mount stores on mainnet") +} + +// Testnet and devnet already ran v1.20.0, so it keeps its handler and EVM stores there. +func TestV1200ActiveOnNonMainnet(t *testing.T) { + for _, chainID := range []string{"lumera-testnet-2", "lumera-devnet-1"} { + params := newTestUpgradeParams(chainID) + config, found := SetupUpgrades(upgrade_v1_20_0.UpgradeName, params) + require.True(t, found) + require.NotNil(t, config.Handler, "v1.20.0 should run on %s", chainID) + require.NotNil(t, config.StoreUpgrade, "v1.20.0 should mount EVM stores on %s", chainID) + require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.0 should add evm store key on %s", chainID) + } +} + +// On mainnet v1.20.1 must carry everything v1.20.0 would have: the EVM store +// additions and the full EVM bring-up handler. +func TestV1201CarriesEVMBringupOnMainnet(t *testing.T) { + params := newTestUpgradeParams("lumera-mainnet-1") + config, found := SetupUpgrades(upgradeNameV1201, params) + require.True(t, found) + require.NotNil(t, config.Handler, "v1.20.1 must carry a handler on mainnet") + require.NotNil(t, config.StoreUpgrade, "v1.20.1 must mount the EVM stores on mainnet") + require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 mainnet should add feemarket store key") + require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 mainnet should add precisebank store key") + require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 mainnet should add evm store key") + require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 mainnet should add erc20 store key") +} + +// On networks that already ran v1.20.0, v1.20.1 is a plain migration-only hotfix. +func TestV1201MigrationOnlyOnNonMainnet(t *testing.T) { + for _, chainID := range []string{"lumera-testnet-2", "lumera-devnet-1"} { + params := newTestUpgradeParams(chainID) + config, found := SetupUpgrades(upgradeNameV1201, params) + require.True(t, found) + require.NotNil(t, config.Handler, "v1.20.1 should still run migrations on %s", chainID) + require.Nil(t, config.StoreUpgrade, "v1.20.1 should not mount stores on %s", chainID) + } +} + func newTestUpgradeParams(chainID string) appParams.AppUpgradeParams { return appParams.AppUpgradeParams{ ChainID: chainID, @@ -163,6 +219,9 @@ func expectHandler(upgradeName, chainID string) bool { switch upgradeName { case upgrade_v1_8_0.UpgradeName: return IsTestnet(chainID) || IsDevnet(chainID) + case upgrade_v1_20_0.UpgradeName: + // Mainnet skips v1.20.0 entirely; the EVM bring-up runs in v1.20.1. + return !IsMainnet(chainID) default: return true } @@ -183,7 +242,11 @@ func expectStoreUpgrade(upgradeName, chainID string) bool { case upgrade_v1_12_0.UpgradeName: return true case upgrade_v1_20_0.UpgradeName: - return true + // EVM stores are added by v1.20.0 only on the networks that run it. + return !IsMainnet(chainID) + case upgradeNameV1201: + // Mainnet carries the EVM store additions on v1.20.1 instead of v1.20.0. + return IsMainnet(chainID) default: return false } From e7b027f27b82f3a3d85a6f6e10014773981c7101 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Sun, 5 Jul 2026 12:16:20 -0400 Subject: [PATCH 21/28] fix(upgrades): route by genesis chain id --- app/app.go | 63 ++++++++++++++++++++++++++++++++++-- app/upgrade_chain_id_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 app/upgrade_chain_id_test.go diff --git a/app/app.go b/app/app.go index f697a71b..c5646483 100644 --- a/app/app.go +++ b/app/app.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "sync" @@ -28,6 +29,7 @@ import ( dbm "github.com/cosmos/cosmos-db" "github.com/cosmos/cosmos-sdk/baseapp" "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" "github.com/cosmos/cosmos-sdk/runtime" @@ -442,7 +444,7 @@ func New( // **** SETUP UPGRADES (upgrade handlers and store loaders) **** // This needs to be done after keepers are initialized but before loading state. - app.setupUpgrades() + app.setupUpgrades(appOpts) /**** Module Options ****/ @@ -490,9 +492,10 @@ func (app *App) GetSubspace(moduleName string) paramstypes.Subspace { // setupUpgrades configures the store loader for upcoming upgrades and registers upgrade handlers. // This needs to be called BEFORE app.Load() -func (app *App) setupUpgrades() { +func (app *App) setupUpgrades(appOpts servertypes.AppOptions) { + chainID := app.upgradeRoutingChainID(appOpts) params := appParams.AppUpgradeParams{ - ChainID: app.ChainID(), + ChainID: chainID, Logger: app.Logger(), ModuleManager: app.ModuleManager, Configurator: app.Configurator(), @@ -576,6 +579,60 @@ func (app *App) setupUpgrades() { app.Logger().Info(selection.LogMessage(), "name", upgradeInfo.Name, "height", upgradeInfo.Height) } +func (app *App) upgradeRoutingChainID(appOpts servertypes.AppOptions) string { + return upgradeRoutingChainID(app.ChainID(), appOpts, app.Logger()) +} + +func upgradeRoutingChainID(setupChainID string, appOpts servertypes.AppOptions, logger log.Logger) string { + chainID := strings.TrimSpace(setupChainID) + if chainID != "" && chainID != Name { + return chainID + } + + genesisChainID, err := chainIDFromGenesisAppOptions(appOpts) + if err != nil { + if logger != nil { + logger.Error("Failed to resolve genesis chain ID for upgrade routing; using setup chain ID", "setup_chain_id", chainID, "error", err) + } + return chainID + } + if genesisChainID == "" { + return chainID + } + + if logger != nil { + logger.Info("Resolved upgrade routing chain ID from genesis", "setup_chain_id", chainID, "genesis_chain_id", genesisChainID) + } + return genesisChainID +} + +func chainIDFromGenesisAppOptions(appOpts servertypes.AppOptions) (string, error) { + if appOpts == nil { + return "", fmt.Errorf("app options are nil") + } + + homeDir := strings.TrimSpace(cast.ToString(appOpts.Get(flags.FlagHome))) + genesisPath := strings.TrimSpace(cast.ToString(appOpts.Get("genesis_file"))) + if genesisPath == "" { + genesisPath = filepath.Join("config", "genesis.json") + } + if !filepath.IsAbs(genesisPath) { + genesisPath = filepath.Join(homeDir, genesisPath) + } + + reader, err := os.Open(genesisPath) + if err != nil { + return "", fmt.Errorf("open genesis file %q: %w", genesisPath, err) + } + defer func() { _ = reader.Close() }() + + chainID, err := genutiltypes.ParseChainIDFromGenesis(reader) + if err != nil { + return "", fmt.Errorf("parse chain-id from genesis file %q: %w", genesisPath, err) + } + return strings.TrimSpace(chainID), nil +} + // LegacyAmino returns App's amino codec. func (app *App) LegacyAmino() *codec.LegacyAmino { return app.legacyAmino diff --git a/app/upgrade_chain_id_test.go b/app/upgrade_chain_id_test.go new file mode 100644 index 00000000..f0412891 --- /dev/null +++ b/app/upgrade_chain_id_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "cosmossdk.io/log" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/stretchr/testify/require" +) + +type upgradeRoutingAppOptions map[string]interface{} + +func (o upgradeRoutingAppOptions) Get(key string) interface{} { + return o[key] +} + +func TestUpgradeRoutingChainIDFallsBackToGenesisForPlaceholder(t *testing.T) { + home := writeGenesisWithChainID(t, "lumera-mainnet-1") + opts := upgradeRoutingAppOptions{ + flags.FlagHome: home, + } + + got := upgradeRoutingChainID(Name, opts, log.NewNopLogger()) + + require.Equal(t, "lumera-mainnet-1", got) +} + +func TestUpgradeRoutingChainIDKeepsExplicitNetworkChainID(t *testing.T) { + home := writeGenesisWithChainID(t, "lumera-mainnet-1") + opts := upgradeRoutingAppOptions{ + flags.FlagHome: home, + } + + got := upgradeRoutingChainID("lumera-testnet-2", opts, log.NewNopLogger()) + + require.Equal(t, "lumera-testnet-2", got) +} + +func TestUpgradeRoutingChainIDKeepsPlaceholderWhenGenesisUnavailable(t *testing.T) { + opts := upgradeRoutingAppOptions{ + flags.FlagHome: t.TempDir(), + } + + got := upgradeRoutingChainID(Name, opts, log.NewNopLogger()) + + require.Equal(t, Name, got) +} + +func writeGenesisWithChainID(t *testing.T, chainID string) string { + t.Helper() + + home := t.TempDir() + configDir := filepath.Join(home, "config") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + genesis := []byte(`{"chain_id":"` + chainID + `"}`) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "genesis.json"), genesis, 0o644)) + return home +} From 8c1c1a7323c91ea132c7b2ffe5c2a43a35e5cff6 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 12:34:46 -0400 Subject: [PATCH 22/28] devnet: wire v1.20.1 upgrade target and release binaries - Makefile.devnet: rename devnet-upgrade-1200 -> devnet-upgrade-1201 and point it (and the scripted devnet-evm-upgrade flow) at v1.20.1; drop the stale devnet-new-1111 target. - binaries.json: pin v1.20.0 to its final tag (was -rc5) and add a v1.20.1 entry (v1.20.1-rc1, bin-v1.20.1, supernode v2.6.0-rc1). Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile.devnet | 17 +++++++---------- devnet/config/binaries.json | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Makefile.devnet b/Makefile.devnet index 3095c03d..e778f036 100644 --- a/Makefile.devnet +++ b/Makefile.devnet @@ -42,8 +42,8 @@ # # -- In-place upgrade (while devnet is running) ---------------- # make devnet-upgrade-version VERSION=v1.12.0 # upgrade to a pre-downloaded release -# make devnet-upgrade-1200 # upgrade to locally-built v1.20.0 (EVM) -# make devnet-evm-upgrade # scripted v1.12.0 -> v1.20.0 evmigration flow +# make devnet-upgrade-1201 # upgrade to locally-built v1.20.1 (EVM) +# make devnet-evm-upgrade # scripted v1.12.0 -> v1.20.1 evmigration flow # # -- External genesis / claims (override inputs to devnet-build) -- # make devnet-build \ @@ -660,8 +660,8 @@ devnet-update-scripts: echo "No containers were updated. Ensure the devnet is running."; \ fi -.PHONY: devnet-new-1111 devnet-new-1120 -.PHONY: devnet-upgrade-1110 devnet-upgrade-1111 devnet-upgrade-1120 devnet-upgrade-1200 +.PHONY: devnet-new-1120 +.PHONY: devnet-upgrade-1110 devnet-upgrade-1111 devnet-upgrade-1120 devnet-upgrade-1201 .PHONY: devnet-evm-upgrade # Upgrade a running devnet to a pre-downloaded lumera version. @@ -697,12 +697,9 @@ devnet-upgrade-1120: # Special case: upgrade to the locally-built version (devnet/bin, not # devnet/bin-). Stays separate from devnet-upgrade-version because # the binary source differs (local repo build vs pre-downloaded release). -devnet-upgrade-1200: +devnet-upgrade-1201: @$(MAKE) devnet-refresh-bin - @cd devnet/scripts && ./upgrade.sh v1.20.0 auto-height ../bin - -devnet-new-1111: - @$(MAKE) devnet-new-version VERSION=v1.11.1 + @cd devnet/scripts && ./upgrade.sh v1.20.1 auto-height ../bin devnet-new-1120: @$(MAKE) devnet-new-version VERSION=v1.12.0 @@ -756,7 +753,7 @@ devnet-evm-upgrade: exit 1; \ fi; \ echo "==> Stage: upgrade to $$EVM_VERSION"; \ - if ! $(MAKE) devnet-upgrade-1200; then \ + if ! $(MAKE) devnet-upgrade-1201; then \ echo "ERROR: stage upgrade to $$EVM_VERSION failed" >&2; \ exit 1; \ fi; \ diff --git a/devnet/config/binaries.json b/devnet/config/binaries.json index 12000f97..5ec8e076 100644 --- a/devnet/config/binaries.json +++ b/devnet/config/binaries.json @@ -98,7 +98,20 @@ "v1.20.0": { "bin_dir": "bin-v1.20.0", "lumera": { - "tag": "v1.20.0-rc5", + "tag": "v1.20.0", + "asset": "lumera_linux_amd64.tar.gz" + }, + "supernode": { + "tag": "v2.6.0-rc1" + }, + "lumera_uploader": { + "tag": "" + } + }, + "v1.20.1": { + "bin_dir": "bin-v1.20.1", + "lumera": { + "tag": "v1.20.1-rc1", "asset": "lumera_linux_amd64.tar.gz" }, "supernode": { From 9c853debdccbba45ed3fce575911b232edb63823 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 12:58:39 -0400 Subject: [PATCH 23/28] docs(design): state-driven v1.20.1 EVM bring-up Design for making the v1.20.1 upgrade carry the EVM bring-up based on chain state (fromVM + committed stores) rather than the IsMainnet chain-id branch, so a direct 1.12.0 -> 1.20.1 one-hop works on any network. Add-only store loader (no delete branch) + fromVM-gated handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...v1_20_1-state-driven-evm-bringup-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/design/v1_20_1-state-driven-evm-bringup-design.md diff --git a/docs/design/v1_20_1-state-driven-evm-bringup-design.md b/docs/design/v1_20_1-state-driven-evm-bringup-design.md new file mode 100644 index 00000000..5164b721 --- /dev/null +++ b/docs/design/v1_20_1-state-driven-evm-bringup-design.md @@ -0,0 +1,91 @@ +# v1.20.1 State-Driven EVM Bring-Up — Design + +## Problem + +The v1.20.1 upgrade currently routes by **network** (`IsMainnet`), not by **state**: + +- **Mainnet**: v1.20.1 carries the full EVM bring-up (reuses `v1_20_0.StoreUpgrades` + `v1_20_0.CreateUpgradeHandler`), because mainnet skips v1.20.0. +- **Testnet / devnet**: v1.20.1 is a migration-only hotfix (`standardUpgradeHandler`, no `StoreUpgrade`), because those networks already ran v1.20.0. + +Consequence: a chain that upgrades **directly 1.12.0 → 1.20.1 on a non-mainnet chain-id** (e.g. a devnet rehearsal of the mainnet one-hop) gets neither the EVM stores nor the bring-up logic. The upgrade is broken — EVM store keys are never mounted, EVM params are never finalized, and RunMigrations would run upstream EVM `InitGenesis` with the wrong (`aatom`) denom. + +## Goal + +Make v1.20.1 carry the EVM bring-up **whenever EVM has not yet been initialized, on any network, with no env flag** — and remain a pure migration-only hotfix when EVM is already present. Both layers of the upgrade (store mounting and handler logic) must key off **chain state**, not chain-id. + +## Detection signal + +The SDK-canonical "has this module been initialized" signal is the `fromVM` (module → consensus version) map passed to the handler: + +- A chain that ran v1.20.0 has `fromVM["evm"]` set (v1.20.0 registered all four EVM modules). +- A chain that skipped v1.20.0 does not. + +`evmtypes.ModuleName == "evm"`. The presence of that key in `fromVM` is the state signal for the handler. Upgrades are atomic (a failed handler aborts the block with no commit), so store presence and `fromVM` presence are always consistent — v1.20.0 either fully ran (stores mounted + module versions registered) or not at all. + +## Design + +### 1. New `app/upgrades/v1_20_1` package — state-driven handler + +``` +const UpgradeName = "v1.20.1" + +CreateUpgradeHandler(p): + return func(ctx, plan, fromVM): + if "evm" NOT in fromVM: // EVM never brought up (one-hop) + → return v1_20_0.CreateUpgradeHandler(p)(ctx, plan, fromVM) // full bring-up + else: // already ran v1.20.0 + → return p.ModuleManager.RunMigrations(ctx, p.Configurator, fromVM) // hotfix only +``` + +This one handler replaces **both** the mainnet `v1_20_0` handler reuse and the non-mainnet `standardUpgradeHandler`. No import cycle: `v1_20_1` imports `v1_20_0`; `v1_20_0` does not import `v1_20_1`. + +A small exported predicate `evmAlreadyInitialized(fromVM) bool` is extracted so the branch is unit-testable without wiring keepers. + +### 2. Add-only, state-driven store loader + +New loader `AddOnlyStoreLoader(height, baseUpgrades, logger)` in `store_upgrade_manager.go`: + +- At load time, read committed store names (reuse `loadExistingStoreNames`). +- Compute `Added = baseUpgrades.Added \ existing` — the declared EVM stores that are **absent** from committed state. +- Never computes `Deleted` or `Renamed`. It can only add stores; it can never wipe one. +- If `Added` is empty → `DefaultStoreLoader` (no-op). Otherwise `ms.LoadLatestVersionAndUpgrade(&StoreUpgrades{Added})`. + +This is a deliberately narrowed form of `AdaptiveStoreLoader`: it keeps the state-driven "add what's missing" half and drops the `Deleted = existing − expected` half, because that half would silently `deleteKVStore()` any store the running binary fails to register — turning a wiring regression into irreversible, consensus-splitting data loss on mainnet. Add-only makes that class of bug impossible. + +### 3. Routing changes + +- `SetupUpgrades`, `upgradeNameV1201` case: return `StoreUpgrade = &v1_20_0.StoreUpgrades` **on all networks** and `Handler = v1_20_1.CreateUpgradeHandler(params)`. The `IsMainnet` branch is removed. +- `StoreLoaderForUpgrade`: route `v1.20.1` to `AddOnlyStoreLoader` **before** the adaptive/non-adaptive split, so it applies uniformly regardless of the `LUMERA_ENABLE_STORE_UPGRADE_MANAGER` env flag or chain-id. +- `v1.20.0` routing is unchanged: mainnet still skips it (`NoUpgradeConfig`); testnet/existing-devnet keep it in history. +- `app.go setupUpgrades` needs no change: v1.20.1 now always has a non-nil `StoreUpgrade`, so it passes the existing `StoreUpgrade == nil && !useAdaptive → return` guard and reaches the loader selection, where `StoreLoaderForUpgrade` hands back the add-only loader. + +### 4. Behavior matrix + +| Chain state | Stores (add-only loader) | Handler (`fromVM`-gated) | +|---|---|---| +| One-hop 1.12.0 → 1.20.1 (mainnet, or any non-mainnet rehearsal) | EVM keys absent → **added** | `"evm"` absent → **full v1.20.0 bring-up** | +| Already ran 1.20.0, then 1.20.1 hotfix | EVM keys present → **no-op** | `"evm"` present → **migrations only** | +| Staged 1.20.0 → 1.20.1 (unchanged) | 1.20.0 mounts; 1.20.1 no-op | 1.20.0 brings up; 1.20.1 migrates only | + +## Testing + +- `evmAlreadyInitialized`: true when `fromVM` has `"evm"`, false otherwise (table test). +- Add-only store computation: `Added = base \ existing`; empty when all present; full when all absent; partial mix. +- `SetupUpgrades` routing for v1.20.1: `StoreUpgrade` non-nil and contains the five EVM store keys for **mainnet, testnet, and devnet** chain-ids; `Handler` non-nil for all. +- The full bring-up internals (migration_end_time per network, ERC20 params, denom metadata) remain covered by the existing `v1_20_0/upgrade_test.go` — not re-tested. + +### Existing tests affected + +- `TestV1201MigrationOnlyOnNonMainnet` asserts `config.StoreUpgrade == nil` on non-mainnet. That assertion **flips** — v1.20.1 now declares the EVM store set on every network. Rewrite it (e.g. `TestV1201CarriesEVMBringupOnAllNetworks`) to assert the store set is present on testnet/devnet too. +- `TestV1201CarriesEVMBringupOnMainnet` still passes (mainnet still gets `StoreUpgrade` + a non-nil handler). + +## Docs + +- Update the upgrade overview table in `upgrades.go` (the `v1.20.1` row) to describe state-driven bring-up rather than mainnet-only. +- No user-facing doc changes; this is upgrade-routing internals. + +## Out of scope + +- No change to `v1.20.0` routing or handler. +- No change to the full `AdaptiveStoreLoader` / its devnet+env gating (still available for the general "skip intermediate upgrades" devnet use case). +- No mainnet use of the delete-capable adaptive diff. From 72c5ff9a0374be0e8974d3b5e34e20290008c5df Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 13:10:55 -0400 Subject: [PATCH 24/28] feat(upgrades): state-driven v1.20.1 EVM bring-up Route the v1.20.1 EVM bring-up by chain STATE instead of the IsMainnet chain-id branch, so a direct 1.12.0 -> 1.20.1 upgrade works on any network (e.g. a devnet rehearsal of the mainnet one-hop), while remaining a no-op migration-only hotfix on chains that already ran v1.20.0. - New app/upgrades/v1_20_1 package: fromVM-gated handler. When the EVM module is absent from fromVM (EVM never brought up) it delegates to the full v1.20.0 bring-up; otherwise it runs migrations only. evmAlreadyInitialized predicate is unit-tested. - AddOnlyStoreLoader / computeAddOnlyStoreUpgrades: mount only the declared EVM store keys missing from committed state; never delete or rename. This is the safe half of AdaptiveStoreLoader (no Deleted branch), so a store-registration regression can never wipe a live store on mainnet. - SetupUpgrades: v1.20.1 declares the EVM store set + the state-driven handler on every network (IsMainnet branch removed). StoreLoaderForUpgrade routes v1.20.1 to the add-only loader regardless of the adaptive-store-manager env flag. - Tests: add-only computation, add-only routing (adaptive and non-adaptive), handler predicate, and SetupUpgrades routing across mainnet/testnet/devnet. TestV1201MigrationOnlyOnNonMainnet (asserted StoreUpgrade==nil) replaced by TestV1201CarriesEVMBringupOnAllNetworks; the mainnet full-bring-up integration test still passes through the new handler. Design: docs/design/v1_20_1-state-driven-evm-bringup-design.md Co-Authored-By: Claude Opus 4.8 (1M context) --- app/upgrades/store_loader_selector.go | 13 ++++ app/upgrades/store_loader_selector_test.go | 21 +++++++ app/upgrades/store_upgrade_manager.go | 66 ++++++++++++++++++++ app/upgrades/store_upgrade_manager_test.go | 62 +++++++++++++++++++ app/upgrades/upgrades.go | 36 +++++------ app/upgrades/upgrades_test.go | 72 +++++++++++----------- app/upgrades/v1_20_1/upgrade.go | 56 +++++++++++++++++ app/upgrades/v1_20_1/upgrade_test.go | 34 ++++++++++ 8 files changed, 305 insertions(+), 55 deletions(-) create mode 100644 app/upgrades/v1_20_1/upgrade.go create mode 100644 app/upgrades/v1_20_1/upgrade_test.go diff --git a/app/upgrades/store_loader_selector.go b/app/upgrades/store_loader_selector.go index de86ef88..c1941eb1 100644 --- a/app/upgrades/store_loader_selector.go +++ b/app/upgrades/store_loader_selector.go @@ -11,6 +11,7 @@ import ( upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" ) type StoreLoaderSelection struct { @@ -28,6 +29,18 @@ func StoreLoaderForUpgrade( logger log.Logger, adaptive bool, ) StoreLoaderSelection { + // v1.20.1 always uses the add-only store loader, on every network and + // regardless of the adaptive-store-manager env flag. It mounts the declared + // EVM store keys that are absent from committed state and never deletes a + // store, so it is safe on mainnet and a no-op on chains that already ran + // v1.20.0. See the v1.20.1 case in SetupUpgrades. + if upgradeName == upgrade_v1_20_1.UpgradeName { + return StoreLoaderSelection{ + Loader: AddOnlyStoreLoader(upgradeHeight, baseUpgrades, logger), + LogLabel: "add-only EVM bring-up", + } + } + if adaptive { if upgradeName == upgrade_v1_10_1.UpgradeName { return StoreLoaderSelection{ diff --git a/app/upgrades/store_loader_selector_test.go b/app/upgrades/store_loader_selector_test.go index 71c3afa1..2252ba17 100644 --- a/app/upgrades/store_loader_selector_test.go +++ b/app/upgrades/store_loader_selector_test.go @@ -8,6 +8,7 @@ import ( upgrade_v1_10_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_10_1" upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" ) func TestStoreLoaderForUpgrade_AdaptiveConsensusRename(t *testing.T) { @@ -80,6 +81,26 @@ func TestStoreLoaderForUpgrade_NonAdaptiveAuditStore(t *testing.T) { require.Equal(t, "Configured store loader for upgrade (conditional audit store)", selection.LogMessage()) } +// v1.20.1 must select the add-only loader regardless of whether the adaptive +// store manager is enabled, so the state-driven EVM bring-up works flag-free on +// every network. +func TestStoreLoaderForUpgrade_V1201AddOnlyRegardlessOfAdaptive(t *testing.T) { + for _, adaptive := range []bool{true, false} { + selection := StoreLoaderForUpgrade( + upgrade_v1_20_1.UpgradeName, + 100, + nil, + map[string]struct{}{}, + log.NewNopLogger(), + adaptive, + ) + + require.NotNil(t, selection.Loader) + require.Equal(t, "Configured store loader for upgrade (add-only EVM bring-up)", selection.LogMessage(), + "v1.20.1 should use the add-only loader with adaptive=%v", adaptive) + } +} + func TestStoreLoaderForUpgrade_NonAdaptiveDefault(t *testing.T) { selection := StoreLoaderForUpgrade( "v9.9.9", diff --git a/app/upgrades/store_upgrade_manager.go b/app/upgrades/store_upgrade_manager.go index c90e487f..3cb282b8 100644 --- a/app/upgrades/store_upgrade_manager.go +++ b/app/upgrades/store_upgrade_manager.go @@ -77,6 +77,72 @@ func AdaptiveStoreLoader( } } +// AddOnlyStoreLoader builds a store loader that mounts the store keys declared in +// baseUpgrades.Added that are NOT already present in committed state, and never +// deletes or renames anything. Unlike AdaptiveStoreLoader it does not diff against +// the app's expected store set, so it cannot be tricked by a binary that fails to +// register a store into wiping that store's data. It is safe on any network, +// which is why the state-driven v1.20.1 EVM bring-up uses it: on a chain that +// already mounted the EVM stores (ran v1.20.0) it is a no-op; on a one-hop chain +// it mounts the missing EVM stores. +func AddOnlyStoreLoader( + upgradeHeight int64, + baseUpgrades *storetypes.StoreUpgrades, + logger log.Logger, +) baseapp.StoreLoader { + fallbackLoader := upgradetypes.UpgradeStoreLoader(upgradeHeight, baseUpgrades) + + return func(ms storetypes.CommitMultiStore) error { + if upgradeHeight != ms.LastCommitID().Version+1 { + return baseapp.DefaultStoreLoader(ms) + } + + existingStoreNames, err := loadExistingStoreNames(ms) + if err != nil { + logger.Error("Failed to load existing stores; falling back to standard upgrade loader", "error", err) + return fallbackLoader(ms) + } + + effective := computeAddOnlyStoreUpgrades(baseUpgrades, existingStoreNames) + if len(effective.Added) == 0 { + logger.Info("No stores to add after diff; loading latest version", "height", upgradeHeight) + return baseapp.DefaultStoreLoader(ms) + } + + logger.Info( + "Applying add-only store upgrades", + "height", upgradeHeight, + "added", effective.Added, + ) + + return ms.LoadLatestVersionAndUpgrade(&effective) + } +} + +// computeAddOnlyStoreUpgrades returns the subset of baseUpgrades.Added that is not +// already present in committed state. Deletions and renames declared on +// baseUpgrades are intentionally dropped: this loader can only add stores. +func computeAddOnlyStoreUpgrades( + baseUpgrades *storetypes.StoreUpgrades, + existingStoreNames map[string]struct{}, +) storetypes.StoreUpgrades { + if baseUpgrades == nil { + return storetypes.StoreUpgrades{} + } + if existingStoreNames == nil { + existingStoreNames = map[string]struct{}{} + } + + added := make(map[string]struct{}) + for _, name := range baseUpgrades.Added { + if _, exists := existingStoreNames[name]; !exists { + added[name] = struct{}{} + } + } + + return storetypes.StoreUpgrades{Added: sortedKeys(added)} +} + type commitInfoReader interface { GetCommitInfo(int64) (*storetypes.CommitInfo, error) } diff --git a/app/upgrades/store_upgrade_manager_test.go b/app/upgrades/store_upgrade_manager_test.go index 22082ac4..5eaf0c33 100644 --- a/app/upgrades/store_upgrade_manager_test.go +++ b/app/upgrades/store_upgrade_manager_test.go @@ -49,6 +49,68 @@ func TestComputeAdaptiveStoreUpgradesKeepsMultipleMissingEVMStores(t *testing.T) require.Empty(t, effective.Deleted) } +func TestComputeAddOnlyStoreUpgradesAddsMissing(t *testing.T) { + existing := setOf("auth", "bank") + base := &storetypes.StoreUpgrades{ + Added: []string{"feemarket", "precisebank", "evm", "erc20", "evmigration"}, + } + + effective := computeAddOnlyStoreUpgrades(base, existing) + + require.ElementsMatch(t, []string{"feemarket", "precisebank", "evm", "erc20", "evmigration"}, effective.Added) + require.Empty(t, effective.Deleted) + require.Empty(t, effective.Renamed) +} + +func TestComputeAddOnlyStoreUpgradesSkipsPresent(t *testing.T) { + existing := setOf("auth", "bank", "feemarket", "evm") + base := &storetypes.StoreUpgrades{ + Added: []string{"feemarket", "precisebank", "evm", "erc20", "evmigration"}, + } + + effective := computeAddOnlyStoreUpgrades(base, existing) + + require.ElementsMatch(t, []string{"precisebank", "erc20", "evmigration"}, effective.Added) + require.Empty(t, effective.Deleted) +} + +func TestComputeAddOnlyStoreUpgradesNoopWhenAllPresent(t *testing.T) { + existing := setOf("auth", "bank", "feemarket", "precisebank", "evm", "erc20", "evmigration") + base := &storetypes.StoreUpgrades{ + Added: []string{"feemarket", "precisebank", "evm", "erc20", "evmigration"}, + } + + effective := computeAddOnlyStoreUpgrades(base, existing) + + require.Empty(t, effective.Added) + require.Empty(t, effective.Deleted) +} + +// The add-only computation must NEVER delete or rename a store, even if the base +// upgrade declares deletions/renames. This is the safety property that prevents a +// binary-misregistration bug from wiping a live store on mainnet. +func TestComputeAddOnlyStoreUpgradesIgnoresDeletesAndRenames(t *testing.T) { + existing := setOf("auth", "bank", "nft", "crisis") + base := &storetypes.StoreUpgrades{ + Added: []string{"evm"}, + Deleted: []string{"nft", "crisis"}, + Renamed: []storetypes.StoreRename{{OldKey: "old", NewKey: "new"}}, + } + + effective := computeAddOnlyStoreUpgrades(base, existing) + + require.ElementsMatch(t, []string{"evm"}, effective.Added) + require.Empty(t, effective.Deleted, "add-only must never delete a store") + require.Empty(t, effective.Renamed, "add-only must never rename a store") +} + +func TestComputeAddOnlyStoreUpgradesNilBase(t *testing.T) { + effective := computeAddOnlyStoreUpgrades(nil, setOf("auth")) + + require.Empty(t, effective.Added) + require.Empty(t, effective.Deleted) +} + func setOf(names ...string) map[string]struct{} { out := make(map[string]struct{}, len(names)) for _, name := range names { diff --git a/app/upgrades/upgrades.go b/app/upgrades/upgrades.go index 1298656b..6345c5dc 100644 --- a/app/upgrades/upgrades.go +++ b/app/upgrades/upgrades.go @@ -17,6 +17,7 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -41,7 +42,7 @@ import ( // | v1.11.1 | custom | conditional add audit store | Supports direct v1.10.1->v1.11.1 and enforces audit min_disk_free_percent floor (>=15) // | v1.12.0 | custom | none (Everlight in supernode) | Runs migrations; Everlight logic embedded in x/supernode // | v1.20.0 | custom | non-mainnet: add feemarket, precisebank, vm, erc20 | EVM bring-up; gated to non-mainnet (mainnet runs it via v1.20.1) -// | v1.20.1 | mixed | mainnet: add feemarket, precisebank, vm, erc20 | Mainnet reuses v1.20.0 store+handler (full EVM bring-up); non-mainnet is migrations only +// | v1.20.1 | custom | state-driven add-only: feemarket, precisebank, vm, erc20 | EVM bring-up when EVM absent (any network, incl. direct 1.12.0->1.20.1); migrations-only hotfix when EVM already present. Add-only store loader mounts only missing keys. // ================================================================================================================================= type UpgradeConfig struct { @@ -51,11 +52,10 @@ type UpgradeConfig struct { // Migration-only upgrades that use the standard handler. const ( - upgradeNameV170 = "v1.7.0" - upgradeNameV172 = "v1.7.2" - upgradeNameV185 = "v1.8.5" - upgradeNameV191 = "v1.9.1" - upgradeNameV1201 = "v1.20.1" + upgradeNameV170 = "v1.7.0" + upgradeNameV172 = "v1.7.2" + upgradeNameV185 = "v1.8.5" + upgradeNameV191 = "v1.9.1" ) // List of all known upgrade names, in chronological order. @@ -74,7 +74,7 @@ var upgradeNames = []string{ upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, - upgradeNameV1201, + upgrade_v1_20_1.UpgradeName, } var NoUpgradeConfig = UpgradeConfig{ @@ -165,19 +165,17 @@ func SetupUpgrades(upgradeName string, params appParams.AppUpgradeParams) (Upgra StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), }, true - case upgradeNameV1201: - // On mainnet v1.20.1 carries the full EVM bring-up that other networks got - // from v1.20.0: it reuses v1.20.0's store additions and handler verbatim, - // so everything v1.20.0 does is guaranteed to run here. On testnet/devnet - // (which already ran v1.20.0) v1.20.1 is a migration-only hotfix. - if IsMainnet(params.ChainID) { - return UpgradeConfig{ - StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, - Handler: upgrade_v1_20_0.CreateUpgradeHandler(params), - }, true - } + case upgrade_v1_20_1.UpgradeName: + // v1.20.1 carries the EVM bring-up based on chain STATE, not chain-id. + // It declares the same EVM store additions as v1.20.0 on every network; + // the add-only store loader (see StoreLoaderForUpgrade) mounts only the + // keys missing from committed state, so this is a no-op on chains that + // already ran v1.20.0 and mounts the EVM stores on a direct 1.12.0->1.20.1 + // one-hop. The handler is likewise state-driven: it runs the full v1.20.0 + // bring-up when the EVM stack is absent from fromVM, else migrations only. return UpgradeConfig{ - Handler: standardUpgradeHandler(upgradeNameV1201, params), + StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, + Handler: upgrade_v1_20_1.CreateUpgradeHandler(params), }, true // add future upgrades here diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index d251bc1c..ef141e46 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -17,6 +17,7 @@ import ( upgrade_v1_11_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_11_1" upgrade_v1_12_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_12_0" upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" + upgrade_v1_20_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_1" upgrade_v1_6_1 "github.com/LumeraProtocol/lumera/app/upgrades/v1_6_1" upgrade_v1_8_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_0" upgrade_v1_8_4 "github.com/LumeraProtocol/lumera/app/upgrades/v1_8_4" @@ -45,7 +46,7 @@ func TestUpgradeNamesOrder(t *testing.T) { upgrade_v1_11_1.UpgradeName, upgrade_v1_12_0.UpgradeName, upgrade_v1_20_0.UpgradeName, - upgradeNameV1201, + upgrade_v1_20_1.UpgradeName, } require.Equal(t, expected, upgradeNames, "upgradeNames should stay in ascending order") } @@ -90,11 +91,11 @@ func TestSetupUpgradesAndHandlers(t *testing.T) { require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.0 should add evm store key") require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.0 should add erc20 store key") } - if upgradeName == upgradeNameV1201 && config.StoreUpgrade != nil { - require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 on mainnet should add feemarket store key") - require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 on mainnet should add precisebank store key") - require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 on mainnet should add evm store key") - require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 on mainnet should add erc20 store key") + if upgradeName == upgrade_v1_20_1.UpgradeName && config.StoreUpgrade != nil { + require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 should declare feemarket store key") + require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 should declare precisebank store key") + require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 should declare evm store key") + require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 should declare erc20 store key") } if config.Handler == nil { @@ -102,8 +103,10 @@ func TestSetupUpgradesAndHandlers(t *testing.T) { } // Custom upgrades that need keepers are skipped in this lightweight harness. - // On mainnet v1.20.1 reuses the v1.20.0 EVM bring-up handler, which also - // needs keepers; on other networks it is migration-only and safe to run. + // v1.20.1 is state-driven: with an empty fromVM (no EVM module) it runs + // the full v1.20.0 EVM bring-up on ANY network, which needs keepers, so + // it is skipped here on all networks (the bring-up path is exercised by + // v1_20_0/upgrade_test.go and the migration-only path by a dedicated test). if upgradeName == upgrade_v1_9_0.UpgradeName || upgradeName == upgrade_v1_10_0.UpgradeName || upgradeName == upgrade_v1_10_1.UpgradeName || @@ -111,7 +114,7 @@ func TestSetupUpgradesAndHandlers(t *testing.T) { upgradeName == upgrade_v1_11_1.UpgradeName || upgradeName == upgrade_v1_12_0.UpgradeName || upgradeName == upgrade_v1_20_0.UpgradeName || - (upgradeName == upgradeNameV1201 && IsMainnet(tt.chainID)) { + upgradeName == upgrade_v1_20_1.UpgradeName { continue } @@ -146,15 +149,19 @@ func TestV1200SkipsEVMInitGenesis(t *testing.T) { "upstream DefaultParams().EvmDenom should be the extended EVM denom — if this changes, review the fromVM skip in v1.20.0") } -func TestV1201IsRegisteredAsMigrationOnlyUpgrade(t *testing.T) { +// When the EVM stack is already present (fromVM carries the EVM module — i.e. the +// chain already ran v1.20.0), v1.20.1 is a migration-only hotfix: it runs +// RunMigrations without touching EVM params, so it needs no EVM keepers. +func TestV1201HandlerMigrationOnlyWhenEVMPresent(t *testing.T) { params := newTestUpgradeParams("lumera-testnet-2") - config, found := SetupUpgrades(upgradeNameV1201, params) + config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) require.True(t, found) require.NotNil(t, config.Handler) - require.Nil(t, config.StoreUpgrade, "v1.20.1 should not declare store changes") + require.NotNil(t, config.StoreUpgrade, "v1.20.1 declares the EVM store set on every network") ctx := sdk.NewContext(nil, tmproto.Header{ChainID: "lumera-testnet-2"}, false, params.Logger) - vm, err := config.Handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + fromVM := module.VersionMap{evmtypes.ModuleName: 1} + vm, err := config.Handler(ctx, upgradetypes.Plan{}, fromVM) require.NoError(t, err) require.NotNil(t, vm) } @@ -181,28 +188,20 @@ func TestV1200ActiveOnNonMainnet(t *testing.T) { } } -// On mainnet v1.20.1 must carry everything v1.20.0 would have: the EVM store -// additions and the full EVM bring-up handler. -func TestV1201CarriesEVMBringupOnMainnet(t *testing.T) { - params := newTestUpgradeParams("lumera-mainnet-1") - config, found := SetupUpgrades(upgradeNameV1201, params) - require.True(t, found) - require.NotNil(t, config.Handler, "v1.20.1 must carry a handler on mainnet") - require.NotNil(t, config.StoreUpgrade, "v1.20.1 must mount the EVM stores on mainnet") - require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 mainnet should add feemarket store key") - require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 mainnet should add precisebank store key") - require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 mainnet should add evm store key") - require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 mainnet should add erc20 store key") -} - -// On networks that already ran v1.20.0, v1.20.1 is a plain migration-only hotfix. -func TestV1201MigrationOnlyOnNonMainnet(t *testing.T) { - for _, chainID := range []string{"lumera-testnet-2", "lumera-devnet-1"} { +// v1.20.1 declares the EVM store additions on EVERY network (the add-only loader +// mounts only the keys missing from committed state) plus a state-driven handler. +// This covers both the mainnet one-hop and a non-mainnet direct 1.12.0 -> 1.20.1. +func TestV1201CarriesEVMBringupOnAllNetworks(t *testing.T) { + for _, chainID := range []string{"lumera-mainnet-1", "lumera-testnet-2", "lumera-devnet-1"} { params := newTestUpgradeParams(chainID) - config, found := SetupUpgrades(upgradeNameV1201, params) + config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) require.True(t, found) - require.NotNil(t, config.Handler, "v1.20.1 should still run migrations on %s", chainID) - require.Nil(t, config.StoreUpgrade, "v1.20.1 should not mount stores on %s", chainID) + require.NotNil(t, config.Handler, "v1.20.1 must carry a handler on %s", chainID) + require.NotNil(t, config.StoreUpgrade, "v1.20.1 must declare the EVM stores on %s", chainID) + require.Contains(t, config.StoreUpgrade.Added, feemarkettypes.StoreKey, "v1.20.1 should add feemarket store key on %s", chainID) + require.Contains(t, config.StoreUpgrade.Added, precisebanktypes.StoreKey, "v1.20.1 should add precisebank store key on %s", chainID) + require.Contains(t, config.StoreUpgrade.Added, evmtypes.StoreKey, "v1.20.1 should add evm store key on %s", chainID) + require.Contains(t, config.StoreUpgrade.Added, erc20types.StoreKey, "v1.20.1 should add erc20 store key on %s", chainID) } } @@ -244,9 +243,10 @@ func expectStoreUpgrade(upgradeName, chainID string) bool { case upgrade_v1_20_0.UpgradeName: // EVM stores are added by v1.20.0 only on the networks that run it. return !IsMainnet(chainID) - case upgradeNameV1201: - // Mainnet carries the EVM store additions on v1.20.1 instead of v1.20.0. - return IsMainnet(chainID) + case upgrade_v1_20_1.UpgradeName: + // v1.20.1 declares the EVM store additions on every network; the add-only + // store loader mounts only the keys missing from committed state. + return true default: return false } diff --git a/app/upgrades/v1_20_1/upgrade.go b/app/upgrades/v1_20_1/upgrade.go new file mode 100644 index 00000000..b94c9ab9 --- /dev/null +++ b/app/upgrades/v1_20_1/upgrade.go @@ -0,0 +1,56 @@ +package v1_20_1 + +import ( + "context" + "fmt" + + upgradetypes "cosmossdk.io/x/upgrade/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + evmtypes "github.com/cosmos/evm/x/vm/types" + + appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" + upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" +) + +// UpgradeName is the on-chain name used for this upgrade. +const UpgradeName = "v1.20.1" + +// evmAlreadyInitialized reports whether the EVM stack was already brought up on +// this chain, using the presence of the EVM (vm) module in the from-version map +// as the signal. v1.20.0 registers all four EVM modules, so a chain that ran it +// carries evmtypes.ModuleName in fromVM; a chain that skipped straight to v1.20.1 +// (e.g. a direct 1.12.0 -> 1.20.1 one-hop) does not. Upgrades are atomic, so this +// is consistent with whether the EVM stores were mounted. +func evmAlreadyInitialized(fromVM module.VersionMap) bool { + _, ok := fromVM[evmtypes.ModuleName] + return ok +} + +// CreateUpgradeHandler returns a state-driven v1.20.1 handler. When the chain has +// not yet initialized the EVM stack it delegates to the full v1.20.0 EVM bring-up +// (params finalization, coin info, ERC20 policy, migration_end_time, and the +// InitGenesis skip). When the EVM stack is already present it is a plain +// migration-only hotfix. This replaces the previous IsMainnet-based routing, so a +// direct 1.12.0 -> 1.20.1 upgrade works on any network. The matching add-only +// store loader mounts the EVM stores when absent, so the bring-up path has them. +func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHandler { + return func(goCtx context.Context, plan upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { + if !evmAlreadyInitialized(fromVM) { + p.Logger.Info(fmt.Sprintf("Starting upgrade %s: EVM not yet initialized, running full v1.20.0 bring-up", UpgradeName)) + return upgrade_v1_20_0.CreateUpgradeHandler(p)(goCtx, plan, fromVM) + } + + p.Logger.Info(fmt.Sprintf("Starting upgrade %s: EVM already initialized, running migration-only hotfix", UpgradeName)) + ctx := sdk.UnwrapSDKContext(goCtx) + + newVM, err := p.ModuleManager.RunMigrations(ctx, p.Configurator, fromVM) + if err != nil { + p.Logger.Error("Failed to run migrations", "error", err) + return nil, fmt.Errorf("failed to run migrations: %w", err) + } + + p.Logger.Info(fmt.Sprintf("Successfully completed upgrade %s", UpgradeName)) + return newVM, nil + } +} diff --git a/app/upgrades/v1_20_1/upgrade_test.go b/app/upgrades/v1_20_1/upgrade_test.go new file mode 100644 index 00000000..bf174f51 --- /dev/null +++ b/app/upgrades/v1_20_1/upgrade_test.go @@ -0,0 +1,34 @@ +package v1_20_1 + +import ( + "testing" + + "github.com/cosmos/cosmos-sdk/types/module" + evmtypes "github.com/cosmos/evm/x/vm/types" + "github.com/stretchr/testify/require" +) + +// A chain that already ran v1.20.0 carries the EVM module in its version map, so +// v1.20.1 must treat it as already-brought-up (migration-only hotfix path). +func TestEVMAlreadyInitializedTrueWhenEVMPresent(t *testing.T) { + fromVM := module.VersionMap{ + "auth": 1, + "bank": 1, + evmtypes.ModuleName: 1, + } + require.True(t, evmAlreadyInitialized(fromVM)) +} + +// A chain that skipped v1.20.0 (direct 1.12.0 -> 1.20.1 one-hop) has no EVM +// module in its version map, so v1.20.1 must run the full bring-up. +func TestEVMAlreadyInitializedFalseWhenEVMAbsent(t *testing.T) { + fromVM := module.VersionMap{ + "auth": 1, + "bank": 1, + } + require.False(t, evmAlreadyInitialized(fromVM)) +} + +func TestEVMAlreadyInitializedFalseForNilMap(t *testing.T) { + require.False(t, evmAlreadyInitialized(nil)) +} From d7c308457f82e11652029203539fb700a4a44c92 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 13:27:45 -0400 Subject: [PATCH 25/28] fix(upgrades): harden state-driven v1.20.1 per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AddOnlyStoreLoader fails closed if it cannot read committed store names, instead of falling back to a blind UpgradeStoreLoader that could re-add already-present EVM stores and halt an already-upgraded chain during load. - v1.20.1 handler bases its decision on ALL four EVM bring-up modules, not just evm: none present -> full bring-up, all present -> migration-only hotfix, and partial state -> hard error (an impossible state that must not silently skip EVM param finalization). - Add a real rootmulti store-loader test: commit a pre-EVM store set, run AddOnlyStoreLoader, and assert the EVM stores get mounted while existing data survives (plus a no-op-when-present case) — exercising the loader through a genuine committed multistore rather than only the pure diff. - Handler tests updated for the all-present hotfix path and the partial-state fail-closed path. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/upgrades/add_only_store_loader_test.go | 107 ++++++++++++++++++ app/upgrades/store_upgrade_manager.go | 11 +- app/upgrades/upgrades_test.go | 26 ++++- app/upgrades/v1_20_1/upgrade.go | 53 +++++++-- app/upgrades/v1_20_1/upgrade_test.go | 53 ++++++--- ...v1_20_1-state-driven-evm-bringup-design.md | 21 ++-- 6 files changed, 228 insertions(+), 43 deletions(-) create mode 100644 app/upgrades/add_only_store_loader_test.go diff --git a/app/upgrades/add_only_store_loader_test.go b/app/upgrades/add_only_store_loader_test.go new file mode 100644 index 00000000..77ca5bc4 --- /dev/null +++ b/app/upgrades/add_only_store_loader_test.go @@ -0,0 +1,107 @@ +package upgrades + +import ( + "testing" + + "cosmossdk.io/log" + "cosmossdk.io/store/metrics" + pruningtypes "cosmossdk.io/store/pruning/types" + "cosmossdk.io/store/rootmulti" + storetypes "cosmossdk.io/store/types" + dbm "github.com/cosmos/cosmos-db" + "github.com/stretchr/testify/require" + + upgrade_v1_20_0 "github.com/LumeraProtocol/lumera/app/upgrades/v1_20_0" +) + +// newTestStore builds a committable in-memory rootmulti store (real no-op metrics +// + pruning options, both required before Commit). +func newTestStore(db dbm.DB) *rootmulti.Store { + ms := rootmulti.NewStore(db, log.NewNopLogger(), metrics.NewNoOpMetrics()) + ms.SetPruning(pruningtypes.NewPruningOptions(pruningtypes.PruningNothing)) + return ms +} + +// mountKeys mounts a KV store for each name into ms and returns the key objects. +// rootmulti indexes its stores by the StoreKey pointer, so callers must reuse the +// returned keys (not a fresh NewKVStoreKey of the same name) when calling +// GetKVStore, or the lookup panics with "store does not exist". +func mountKeys(ms *rootmulti.Store, names []string) map[string]*storetypes.KVStoreKey { + keys := make(map[string]*storetypes.KVStoreKey, len(names)) + for _, name := range names { + key := storetypes.NewKVStoreKey(name) + keys[name] = key + ms.MountStoreWithDB(key, storetypes.StoreTypeIAVL, nil) + } + return keys +} + +func committedStoreNames(t *testing.T, ms *rootmulti.Store) map[string]struct{} { + t.Helper() + names, err := loadExistingStoreNames(ms) + require.NoError(t, err) + return names +} + +// End-to-end through the real store loader: a chain committed WITHOUT the EVM +// stores (the pre-EVM 1.12.0 state) upgrades directly to v1.20.1. The add-only +// loader must mount every declared EVM store and preserve pre-existing data. +func TestAddOnlyStoreLoader_MountsMissingEVMStoresFromPreEVMState(t *testing.T) { + db := dbm.NewMemDB() + base := &upgrade_v1_20_0.StoreUpgrades + + // v1: pre-EVM committed state (auth + bank only), with data in bank. + pre := newTestStore(db) + preKeys := mountKeys(pre, []string{"auth", "bank"}) + require.NoError(t, pre.LoadLatestVersion()) + pre.GetKVStore(preKeys["bank"]).Set([]byte("balance"), []byte("42")) + require.Equal(t, int64(1), pre.Commit().Version) + + // A new binary mounts auth + bank + all EVM stores and runs the v1.20.1 + // add-only loader at the upgrade height (committed version + 1). + next := newTestStore(db) + nextKeys := mountKeys(next, append([]string{"auth", "bank"}, base.Added...)) + require.NoError(t, AddOnlyStoreLoader(2, base, log.NewNopLogger())(next)) + + // Pre-existing bank data survives the mount. + require.Equal(t, []byte("42"), next.GetKVStore(nextKeys["bank"]).Get([]byte("balance")), + "add-only loader must not disturb existing store data") + + // Every declared EVM store is now mounted and writable; committing succeeds. + for _, name := range base.Added { + next.GetKVStore(nextKeys[name]).Set([]byte("k"), []byte("v")) + } + require.Equal(t, int64(2), next.Commit().Version) + + // The committed store set now includes the EVM stores. + committed := committedStoreNames(t, next) + for _, name := range base.Added { + require.Contains(t, committed, name, "committed store set should include %s after upgrade", name) + } +} + +// A chain that already ran v1.20.0 (EVM stores present) applying v1.20.1 must be a +// pure no-op at the store layer: the add-only loader adds nothing and does not +// disturb existing data. This is the hotfix path. +func TestAddOnlyStoreLoader_NoopWhenEVMStoresPresent(t *testing.T) { + db := dbm.NewMemDB() + base := &upgrade_v1_20_0.StoreUpgrades + names := append([]string{"auth", "bank"}, base.Added...) + + // v1: committed state that already includes the EVM stores. + pre := newTestStore(db) + preKeys := mountKeys(pre, names) + require.NoError(t, pre.LoadLatestVersion()) + pre.GetKVStore(preKeys["bank"]).Set([]byte("balance"), []byte("7")) + pre.GetKVStore(preKeys[base.Added[0]]).Set([]byte("evm-key"), []byte("evm-val")) + require.Equal(t, int64(1), pre.Commit().Version) + + // Re-open with the same mounted set and run the add-only loader. + next := newTestStore(db) + nextKeys := mountKeys(next, names) + require.NoError(t, AddOnlyStoreLoader(2, base, log.NewNopLogger())(next)) + + // Both stores' data survive untouched. + require.Equal(t, []byte("7"), next.GetKVStore(nextKeys["bank"]).Get([]byte("balance"))) + require.Equal(t, []byte("evm-val"), next.GetKVStore(nextKeys[base.Added[0]]).Get([]byte("evm-key"))) +} diff --git a/app/upgrades/store_upgrade_manager.go b/app/upgrades/store_upgrade_manager.go index 3cb282b8..f4415019 100644 --- a/app/upgrades/store_upgrade_manager.go +++ b/app/upgrades/store_upgrade_manager.go @@ -90,8 +90,6 @@ func AddOnlyStoreLoader( baseUpgrades *storetypes.StoreUpgrades, logger log.Logger, ) baseapp.StoreLoader { - fallbackLoader := upgradetypes.UpgradeStoreLoader(upgradeHeight, baseUpgrades) - return func(ms storetypes.CommitMultiStore) error { if upgradeHeight != ms.LastCommitID().Version+1 { return baseapp.DefaultStoreLoader(ms) @@ -99,8 +97,13 @@ func AddOnlyStoreLoader( existingStoreNames, err := loadExistingStoreNames(ms) if err != nil { - logger.Error("Failed to load existing stores; falling back to standard upgrade loader", "error", err) - return fallbackLoader(ms) + // Fail closed. The whole point of the add-only diff is to never re-add + // an already-present store; falling back to a blind UpgradeStoreLoader + // here would try to add every declared EVM store, which on a chain that + // already ran v1.20.0 could halt during load. Better a loud, deterministic + // failure than a silent duplicate-add. + logger.Error("Failed to load existing stores for add-only upgrade; failing closed", "error", err) + return fmt.Errorf("add-only store loader: cannot determine existing stores at height %d: %w", upgradeHeight, err) } effective := computeAddOnlyStoreUpgrades(baseUpgrades, existingStoreNames) diff --git a/app/upgrades/upgrades_test.go b/app/upgrades/upgrades_test.go index ef141e46..58995fd7 100644 --- a/app/upgrades/upgrades_test.go +++ b/app/upgrades/upgrades_test.go @@ -149,8 +149,8 @@ func TestV1200SkipsEVMInitGenesis(t *testing.T) { "upstream DefaultParams().EvmDenom should be the extended EVM denom — if this changes, review the fromVM skip in v1.20.0") } -// When the EVM stack is already present (fromVM carries the EVM module — i.e. the -// chain already ran v1.20.0), v1.20.1 is a migration-only hotfix: it runs +// When the EVM stack is already present (fromVM carries ALL four EVM modules — +// i.e. the chain already ran v1.20.0), v1.20.1 is a migration-only hotfix: it runs // RunMigrations without touching EVM params, so it needs no EVM keepers. func TestV1201HandlerMigrationOnlyWhenEVMPresent(t *testing.T) { params := newTestUpgradeParams("lumera-testnet-2") @@ -160,12 +160,32 @@ func TestV1201HandlerMigrationOnlyWhenEVMPresent(t *testing.T) { require.NotNil(t, config.StoreUpgrade, "v1.20.1 declares the EVM store set on every network") ctx := sdk.NewContext(nil, tmproto.Header{ChainID: "lumera-testnet-2"}, false, params.Logger) - fromVM := module.VersionMap{evmtypes.ModuleName: 1} + fromVM := module.VersionMap{ + evmtypes.ModuleName: 1, + feemarkettypes.ModuleName: 1, + precisebanktypes.ModuleName: 1, + erc20types.ModuleName: 1, + } vm, err := config.Handler(ctx, upgradetypes.Plan{}, fromVM) require.NoError(t, err) require.NotNil(t, vm) } +// Partial EVM module state (some present, some absent) cannot arise from any +// correct upgrade path, so v1.20.1 must fail closed rather than silently take the +// migration-only path and skip EVM param finalization. +func TestV1201HandlerFailsClosedOnPartialEVMState(t *testing.T) { + params := newTestUpgradeParams("lumera-testnet-2") + config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) + require.True(t, found) + + ctx := sdk.NewContext(nil, tmproto.Header{ChainID: "lumera-testnet-2"}, false, params.Logger) + fromVM := module.VersionMap{evmtypes.ModuleName: 1} // only 1 of 4 EVM modules + _, err := config.Handler(ctx, upgradetypes.Plan{}, fromVM) + require.Error(t, err) + require.Contains(t, err.Error(), "inconsistent EVM module state") +} + // Mainnet skips v1.20.0 outright: no handler runs and no stores are mounted, so // a mainnet node never applies the (partial, later-superseded) v1.20.0 bring-up. func TestV1200ExcludedOnMainnet(t *testing.T) { diff --git a/app/upgrades/v1_20_1/upgrade.go b/app/upgrades/v1_20_1/upgrade.go index b94c9ab9..14515402 100644 --- a/app/upgrades/v1_20_1/upgrade.go +++ b/app/upgrades/v1_20_1/upgrade.go @@ -7,6 +7,9 @@ import ( upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + erc20types "github.com/cosmos/evm/x/erc20/types" + feemarkettypes "github.com/cosmos/evm/x/feemarket/types" + precisebanktypes "github.com/cosmos/evm/x/precisebank/types" evmtypes "github.com/cosmos/evm/x/vm/types" appParams "github.com/LumeraProtocol/lumera/app/upgrades/params" @@ -16,15 +19,29 @@ import ( // UpgradeName is the on-chain name used for this upgrade. const UpgradeName = "v1.20.1" -// evmAlreadyInitialized reports whether the EVM stack was already brought up on -// this chain, using the presence of the EVM (vm) module in the from-version map -// as the signal. v1.20.0 registers all four EVM modules, so a chain that ran it -// carries evmtypes.ModuleName in fromVM; a chain that skipped straight to v1.20.1 -// (e.g. a direct 1.12.0 -> 1.20.1 one-hop) does not. Upgrades are atomic, so this -// is consistent with whether the EVM stores were mounted. -func evmAlreadyInitialized(fromVM module.VersionMap) bool { - _, ok := fromVM[evmtypes.ModuleName] - return ok +// evmBringUpModules are the four cosmos/evm modules that the v1.20.0 EVM bring-up +// registers together (see v1_20_0.CreateUpgradeHandler). They are the signal for +// whether the bring-up already ran: a chain that ran v1.20.0 carries all four in +// fromVM; a chain that skipped straight to v1.20.1 (a direct 1.12.0 -> 1.20.1 +// one-hop) carries none. Because v1.20.0 registers them atomically, "some but not +// all present" is not a state any correct upgrade path can produce. +var evmBringUpModules = []string{ + evmtypes.ModuleName, + feemarkettypes.ModuleName, + precisebanktypes.ModuleName, + erc20types.ModuleName, +} + +// evmModuleState partitions evmBringUpModules by their presence in fromVM. +func evmModuleState(fromVM module.VersionMap) (present, absent []string) { + for _, name := range evmBringUpModules { + if _, ok := fromVM[name]; ok { + present = append(present, name) + } else { + absent = append(absent, name) + } + } + return present, absent } // CreateUpgradeHandler returns a state-driven v1.20.1 handler. When the chain has @@ -36,11 +53,27 @@ func evmAlreadyInitialized(fromVM module.VersionMap) bool { // store loader mounts the EVM stores when absent, so the bring-up path has them. func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHandler { return func(goCtx context.Context, plan upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) { - if !evmAlreadyInitialized(fromVM) { + present, absent := evmModuleState(fromVM) + + switch { + case len(present) == 0: + // No EVM modules yet — this is a direct one-hop onto v1.20.1. Run the + // full v1.20.0 bring-up (the add-only store loader mounts the stores). p.Logger.Info(fmt.Sprintf("Starting upgrade %s: EVM not yet initialized, running full v1.20.0 bring-up", UpgradeName)) return upgrade_v1_20_0.CreateUpgradeHandler(p)(goCtx, plan, fromVM) + case len(absent) > 0: + // Partial EVM state cannot arise from any correct upgrade path (v1.20.0 + // registers all four modules atomically). Neither branch is safe here: + // the bring-up path would double-init the present modules, and the hotfix + // path would skip param finalization for the absent ones. Fail closed. + return nil, fmt.Errorf( + "%s: inconsistent EVM module state, refusing to run — present=%v absent=%v; expected all EVM modules present (migration-only hotfix) or all absent (full bring-up)", + UpgradeName, present, absent, + ) } + // All EVM modules present: the chain already ran v1.20.0, so v1.20.1 is a + // plain migration-only hotfix. p.Logger.Info(fmt.Sprintf("Starting upgrade %s: EVM already initialized, running migration-only hotfix", UpgradeName)) ctx := sdk.UnwrapSDKContext(goCtx) diff --git a/app/upgrades/v1_20_1/upgrade_test.go b/app/upgrades/v1_20_1/upgrade_test.go index bf174f51..da0c6cfc 100644 --- a/app/upgrades/v1_20_1/upgrade_test.go +++ b/app/upgrades/v1_20_1/upgrade_test.go @@ -4,31 +4,48 @@ import ( "testing" "github.com/cosmos/cosmos-sdk/types/module" + erc20types "github.com/cosmos/evm/x/erc20/types" + feemarkettypes "github.com/cosmos/evm/x/feemarket/types" + precisebanktypes "github.com/cosmos/evm/x/precisebank/types" evmtypes "github.com/cosmos/evm/x/vm/types" "github.com/stretchr/testify/require" ) -// A chain that already ran v1.20.0 carries the EVM module in its version map, so -// v1.20.1 must treat it as already-brought-up (migration-only hotfix path). -func TestEVMAlreadyInitializedTrueWhenEVMPresent(t *testing.T) { - fromVM := module.VersionMap{ - "auth": 1, - "bank": 1, - evmtypes.ModuleName: 1, +// allEVMModules is a fromVM entry for every module the v1.20.0 bring-up registers. +func allEVMModules() module.VersionMap { + vm := module.VersionMap{"auth": 1, "bank": 1} + for _, name := range evmBringUpModules { + vm[name] = 1 } - require.True(t, evmAlreadyInitialized(fromVM)) + return vm } -// A chain that skipped v1.20.0 (direct 1.12.0 -> 1.20.1 one-hop) has no EVM -// module in its version map, so v1.20.1 must run the full bring-up. -func TestEVMAlreadyInitializedFalseWhenEVMAbsent(t *testing.T) { - fromVM := module.VersionMap{ - "auth": 1, - "bank": 1, - } - require.False(t, evmAlreadyInitialized(fromVM)) +// A chain that ran v1.20.0 carries all four EVM modules -> nothing absent +// (migration-only hotfix path). +func TestEVMModuleStateAllPresent(t *testing.T) { + present, absent := evmModuleState(allEVMModules()) + require.ElementsMatch(t, evmBringUpModules, present) + require.Empty(t, absent) } -func TestEVMAlreadyInitializedFalseForNilMap(t *testing.T) { - require.False(t, evmAlreadyInitialized(nil)) +// A direct 1.12.0 -> 1.20.1 one-hop carries no EVM modules -> all absent +// (full bring-up path). +func TestEVMModuleStateAllAbsent(t *testing.T) { + present, absent := evmModuleState(module.VersionMap{"auth": 1, "bank": 1}) + require.Empty(t, present) + require.ElementsMatch(t, evmBringUpModules, absent) +} + +// Partial state (some EVM modules present, others not) must be detectable so the +// handler can fail closed rather than silently skip param finalization. +func TestEVMModuleStatePartial(t *testing.T) { + fromVM := module.VersionMap{ + "auth": 1, + evmtypes.ModuleName: 1, + feemarkettypes.ModuleName: 1, + // precisebank and erc20 intentionally absent. + } + present, absent := evmModuleState(fromVM) + require.ElementsMatch(t, []string{evmtypes.ModuleName, feemarkettypes.ModuleName}, present) + require.ElementsMatch(t, []string{precisebanktypes.ModuleName, erc20types.ModuleName}, absent) } diff --git a/docs/design/v1_20_1-state-driven-evm-bringup-design.md b/docs/design/v1_20_1-state-driven-evm-bringup-design.md index 5164b721..ead6c8fd 100644 --- a/docs/design/v1_20_1-state-driven-evm-bringup-design.md +++ b/docs/design/v1_20_1-state-driven-evm-bringup-design.md @@ -15,12 +15,13 @@ Make v1.20.1 carry the EVM bring-up **whenever EVM has not yet been initialized, ## Detection signal -The SDK-canonical "has this module been initialized" signal is the `fromVM` (module → consensus version) map passed to the handler: +The SDK-canonical "has this module been initialized" signal is the `fromVM` (module → consensus version) map passed to the handler. v1.20.0 registers **all four** cosmos/evm modules atomically (`evm`, `feemarket`, `precisebank`, `erc20`), so the handler inspects the whole set rather than a single module: -- A chain that ran v1.20.0 has `fromVM["evm"]` set (v1.20.0 registered all four EVM modules). -- A chain that skipped v1.20.0 does not. +- **None present** → a chain that skipped v1.20.0 (direct one-hop) → run the full bring-up. +- **All present** → a chain that ran v1.20.0 → migration-only hotfix. +- **Some but not all present** → an impossible state under any correct upgrade path → **fail closed** with an error, rather than silently taking the hotfix path and skipping param finalization for the absent modules. -`evmtypes.ModuleName == "evm"`. The presence of that key in `fromVM` is the state signal for the handler. Upgrades are atomic (a failed handler aborts the block with no commit), so store presence and `fromVM` presence are always consistent — v1.20.0 either fully ran (stores mounted + module versions registered) or not at all. +Upgrades are atomic (a failed handler aborts the block with no commit), so store presence and `fromVM` presence are always consistent — v1.20.0 either fully ran (stores mounted + all four module versions registered) or not at all. ## Design @@ -49,6 +50,7 @@ New loader `AddOnlyStoreLoader(height, baseUpgrades, logger)` in `store_upgrade_ - Compute `Added = baseUpgrades.Added \ existing` — the declared EVM stores that are **absent** from committed state. - Never computes `Deleted` or `Renamed`. It can only add stores; it can never wipe one. - If `Added` is empty → `DefaultStoreLoader` (no-op). Otherwise `ms.LoadLatestVersionAndUpgrade(&StoreUpgrades{Added})`. +- **Fails closed** if `loadExistingStoreNames` errors: it returns the error rather than falling back to a blind `UpgradeStoreLoader(baseUpgrades)`. A blind fallback would try to add every declared EVM store, which on a chain that already ran v1.20.0 could halt during load — defeating the whole no-op guarantee. A loud, deterministic failure is preferable. This is a deliberately narrowed form of `AdaptiveStoreLoader`: it keeps the state-driven "add what's missing" half and drops the `Deleted = existing − expected` half, because that half would silently `deleteKVStore()` any store the running binary fails to register — turning a wiring regression into irreversible, consensus-splitting data loss on mainnet. Add-only makes that class of bug impossible. @@ -69,10 +71,13 @@ This is a deliberately narrowed form of `AdaptiveStoreLoader`: it keeps the stat ## Testing -- `evmAlreadyInitialized`: true when `fromVM` has `"evm"`, false otherwise (table test). -- Add-only store computation: `Added = base \ existing`; empty when all present; full when all absent; partial mix. -- `SetupUpgrades` routing for v1.20.1: `StoreUpgrade` non-nil and contains the five EVM store keys for **mainnet, testnet, and devnet** chain-ids; `Handler` non-nil for all. -- The full bring-up internals (migration_end_time per network, ERC20 params, denom metadata) remain covered by the existing `v1_20_0/upgrade_test.go` — not re-tested. +- `evmModuleState`: partitions the four EVM modules into present/absent (all-present, all-absent, partial mix). +- Handler paths: migration-only when all four present; **hard error** on partial state; the full-bring-up path (none present) is exercised end-to-end by the external mainnet test. +- Add-only store computation: `Added = base \ existing`; empty when all present; full when all absent; partial mix; deletes/renames on the base are dropped. +- **Real store loader** (`add_only_store_loader_test.go`): commit a pre-EVM store set (`auth`+`bank`) to an in-memory `rootmulti`, run `AddOnlyStoreLoader` at the upgrade height, and assert the EVM stores get mounted while existing data survives; plus a no-op case where the EVM stores already exist. This exercises the loader through a genuine committed multistore, not just the pure diff. +- Add-only routing (`StoreLoaderForUpgrade`) selects the add-only loader for v1.20.1 with adaptive on **and** off. +- `SetupUpgrades` routing for v1.20.1: `StoreUpgrade` non-nil and contains the EVM store keys for **mainnet, testnet, and devnet** chain-ids; `Handler` non-nil for all. +- The full bring-up internals (migration_end_time per network, ERC20 params, denom metadata) remain covered by `v1_20_0/upgrade_test.go` and the external mainnet integration test — not re-tested. ### Existing tests affected From a028184171cc126a5a54bf44cdeb5e2efad09641 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 13:49:56 -0400 Subject: [PATCH 26/28] devnet: target v1.20.1 in scripted evm-upgrade flow The devnet-evm-upgrade recipe still pinned EVM_VERSION=v1.20.0; bump it to v1.20.1 to match the renamed devnet-upgrade-1201 target and the v1.20.1 release wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile.devnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile.devnet b/Makefile.devnet index e778f036..257ef9b5 100644 --- a/Makefile.devnet +++ b/Makefile.devnet @@ -711,7 +711,7 @@ devnet-evm-upgrade: @echo "Logging to $(DEVNET_EVM_UPGRADE_LOG)" @bash -c 'set -euo pipefail; { \ BASE_VERSION=v1.12.0; \ - EVM_VERSION=v1.20.0; \ + EVM_VERSION=v1.20.1; \ echo "==> Stage: install $$BASE_VERSION devnet"; \ if ! $(MAKE) devnet-down; then \ echo "ERROR: stage install $$BASE_VERSION devnet failed during devnet-down" >&2; \ From ca8224bf103dbb360b84b66b143d27e428b503e4 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 13:49:56 -0400 Subject: [PATCH 27/28] chore(devnet): tidy module deps after x/net dependabot bump The dependabot merge (#185, #186) re-tidied the root and tests/systemtests modules but not the nested devnet module. go mod tidy in devnet re-resolves the shared golang.org/x/* deps (crypto 0.49->0.51, net 0.52->0.55, sys, term, text) to the same versions now in root, carrying the x/net security bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- devnet/go.mod | 10 +++++----- devnet/go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/devnet/go.mod b/devnet/go.mod index 49999d9b..97585ef3 100644 --- a/devnet/go.mod +++ b/devnet/go.mod @@ -40,7 +40,7 @@ require ( github.com/ethereum/go-ethereum v1.17.0 github.com/gorilla/websocket v1.5.3 github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.51.0 ) require ( @@ -217,11 +217,11 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect diff --git a/devnet/go.sum b/devnet/go.sum index 0cd9b11c..8b5614f9 100644 --- a/devnet/go.sum +++ b/devnet/go.sum @@ -1140,8 +1140,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1238,8 +1238,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1351,8 +1351,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1362,8 +1362,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1380,8 +1380,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 400e8ea9cf304392748c3d3d993cccb2df6d4899 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Mon, 6 Jul 2026 13:54:09 -0400 Subject: [PATCH 28/28] test(evmigration): correct stale "orphaned validator" comments Two comments in migration_test.go still described the old validator record as "orphaned / left dangling" with the rationale that RemoveValidator can't run on a bonded validator. V8 now deletes the old record via DeleteValidatorRecordNoHooks (a raw KV delete that avoids those hooks), so the record is gone, not orphaned. Comment-only; matches the existing V8 phrasing already used elsewhere in the file. Raised in PR review (Bilaltanveer38). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/evmigration/migration_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/integration/evmigration/migration_test.go b/tests/integration/evmigration/migration_test.go index 89183cd3..e44be551 100644 --- a/tests/integration/evmigration/migration_test.go +++ b/tests/integration/evmigration/migration_test.go @@ -717,8 +717,9 @@ func (s *MigrationIntegrationSuite) TestMigrateValidator_Success() { s.Require().NotNil(resp) // --- Verify validator record re-keyed --- - // The old validator key is orphaned (RemoveValidator cannot be used on bonded - // validators without destroying distribution state). The new record is canonical. + // The old validator record is deleted by V8 (DeleteValidatorRecordNoHooks — a + // raw KV delete that avoids RemoveValidator's hooks, which would destroy + // distribution state). The new record is canonical. newVal, err := s.app.StakingKeeper.GetValidator(s.ctx, newValAddr) s.Require().NoError(err, "new validator should exist") s.Require().Equal(newValAddr.String(), newVal.OperatorAddress) @@ -1205,9 +1206,10 @@ func (s *MigrationIntegrationSuite) TestMigrateValidator_MultisigToMultisig() { s.Require().Equal(newValAddr.String(), newVal.OperatorAddress) s.Require().Equal(stakingtypes.Bonded, newVal.Status) - // Old operator's validator record is orphaned (no delegations remain); - // removing a bonded validator record outright would destroy distribution - // state, so the migration leaves it dangling. The new record is canonical. + // Old operator's delegations are all re-keyed away (none remain); its validator + // record is deleted by V8 (DeleteValidatorRecordNoHooks — a raw KV delete that + // avoids RemoveValidator's distribution-destroying hooks), leaving no orphan. + // The new record is canonical. oldDels, err := s.app.StakingKeeper.GetValidatorDelegations(s.ctx, oldValAddr) s.Require().NoError(err) s.Require().Empty(oldDels)