diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b192781..0ccd0097 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 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`) + +- 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 + +- 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. + +--- + ## 1.20.0 Changes included since `v1.11.1` (range: `v1.11.1..v1.20.0`). diff --git a/Makefile.devnet b/Makefile.devnet index 3095c03d..257ef9b5 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 @@ -714,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; \ @@ -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/app/app.go b/app/app.go index c7938745..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" @@ -54,6 +56,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 +369,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 { @@ -438,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 ****/ @@ -486,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(), @@ -572,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 +} 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_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..f4415019 100644 --- a/app/upgrades/store_upgrade_manager.go +++ b/app/upgrades/store_upgrade_manager.go @@ -77,6 +77,75 @@ 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 { + return func(ms storetypes.CommitMultiStore) error { + if upgradeHeight != ms.LastCommitID().Version+1 { + return baseapp.DefaultStoreLoader(ms) + } + + existingStoreNames, err := loadExistingStoreNames(ms) + if err != nil { + // 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) + 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 10f8371f..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" @@ -40,7 +41,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.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 | 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 { @@ -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{ @@ -152,10 +155,28 @@ 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 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{ + StoreUpgrade: &upgrade_v1_20_0.StoreUpgrades, + Handler: upgrade_v1_20_1.CreateUpgradeHandler(params), + }, true // add future upgrades here default: 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 ac1fa6d3..58995fd7 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") } @@ -89,19 +91,30 @@ 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 == 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 { continue } // Custom upgrades that need keepers are skipped in this lightweight harness. + // 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 || 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 == upgrade_v1_20_1.UpgradeName { continue } @@ -136,6 +149,82 @@ 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 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") + config, found := SetupUpgrades(upgrade_v1_20_1.UpgradeName, params) + require.True(t, found) + require.NotNil(t, config.Handler) + 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, + 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) { + 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) + } +} + +// 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(upgrade_v1_20_1.UpgradeName, params) + require.True(t, found) + 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) + } +} + func newTestUpgradeParams(chainID string) appParams.AppUpgradeParams { return appParams.AppUpgradeParams{ ChainID: chainID, @@ -149,6 +238,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 } @@ -169,6 +261,11 @@ func expectStoreUpgrade(upgradeName, chainID string) bool { case upgrade_v1_12_0.UpgradeName: return true 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 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..14515402 --- /dev/null +++ b/app/upgrades/v1_20_1/upgrade.go @@ -0,0 +1,89 @@ +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" + 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" + 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" + +// 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 +// 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) { + 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) + + 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..da0c6cfc --- /dev/null +++ b/app/upgrades/v1_20_1/upgrade_test.go @@ -0,0 +1,51 @@ +package v1_20_1 + +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" +) + +// 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 + } + return vm +} + +// 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) +} + +// 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/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": { 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/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= 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/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/design/2026-06-22-validator-migration-gas-design.md b/docs/design/2026-06-22-validator-migration-gas-design.md index 318b5b87..11a4e925 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,85 @@ 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. + +--- + +## 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/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..ead6c8fd --- /dev/null +++ b/docs/design/v1_20_1-state-driven-evm-bringup-design.md @@ -0,0 +1,96 @@ +# 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. 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: + +- **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. + +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 + +### 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})`. +- **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. + +### 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 + +- `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 + +- `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. 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`. 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/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 diff --git a/docs/evm-integration/evmigration/legacy-migration.md b/docs/evm-integration/evmigration/legacy-migration.md index c48302a6..4318a6a6 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 @@ -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.md b/docs/evm-integration/testing/tests.md index 873c7c09..e6cda10a 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) | @@ -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) | @@ -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 | @@ -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 278cffd6..55c77bf4 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -17,13 +17,52 @@ 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. | +| `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 +``` + +### 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. +- 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/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index 42665232..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. | @@ -114,6 +113,20 @@ 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. | +| `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/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. 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/scripts/chain-helper.sh b/scripts/chain-helper.sh index da91b02e..d181b723 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}}" @@ -19,6 +32,18 @@ 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' Usage: @@ -33,11 +58,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 +127,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 +375,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 +655,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/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 2cf0a242..e44be551 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" ) @@ -82,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)) @@ -91,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)) @@ -99,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) @@ -124,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(), @@ -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. @@ -683,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) @@ -845,6 +880,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() { @@ -1098,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) diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index 262bc83b..055a66f8 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -262,6 +262,147 @@ 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 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 "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" \ + --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 \ @@ -373,6 +514,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" \ 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/keeper.go b/x/evmigration/keeper/keeper.go index c57b58f3..a787f503 100644 --- a/x/evmigration/keeper/keeper.go +++ b/x/evmigration/keeper/keeper.go @@ -15,11 +15,19 @@ 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 } +// 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 @@ -29,10 +37,14 @@ 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. + distributionStoreHandle *distributionStoreHandle // MigrationRecords stores completed migration records keyed by legacy address. MigrationRecords collections.Map[string, types.MigrationRecord] @@ -57,7 +69,6 @@ type Keeper struct { feegrantKeeper types.FeegrantKeeper supernodeKeeper types.SupernodeKeeper actionKeeper types.ActionKeeper - claimKeeper types.ClaimKeeper } func NewKeeper( @@ -73,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)) @@ -102,12 +112,12 @@ 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 // the staking store service into it post-build. - stakingStoreHandle: &stakingStoreHandle{}, + stakingStoreHandle: &stakingStoreHandle{}, + distributionStoreHandle: &distributionStoreHandle{}, } schema, err := sb.Build() @@ -135,3 +145,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/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_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_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_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_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 b0adff28..6216b1d7 100644 --- a/x/evmigration/keeper/migrate_test.go +++ b/x/evmigration/keeper/migrate_test.go @@ -1,12 +1,16 @@ package keeper_test import ( + "errors" "fmt" + "sort" "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" @@ -25,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" @@ -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 @@ -45,7 +51,6 @@ type mockFixture struct { feegrantKeeper *evmigrationmocks.MockFeegrantKeeper supernodeKeeper *evmigrationmocks.MockSupernodeKeeper actionKeeper *evmigrationmocks.MockActionKeeper - claimKeeper *evmigrationmocks.MockClaimKeeper } func initMockFixture(t *testing.T) *mockFixture { @@ -61,13 +66,24 @@ 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()) 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) @@ -84,7 +100,6 @@ func initMockFixture(t *testing.T) *mockFixture { feegrantKeeper, supernodeKeeper, actionKeeper, - claimKeeper, ) // Initialize params with migration enabled. @@ -96,6 +111,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, @@ -104,10 +122,88 @@ func initMockFixture(t *testing.T) *mockFixture { feegrantKeeper: feegrantKeeper, supernodeKeeper: supernodeKeeper, actionKeeper: actionKeeper, - claimKeeper: claimKeeper, } } +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 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()) } @@ -134,9 +230,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, @@ -1057,103 +1154,86 @@ 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 - }) - - err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) - require.NoError(t, err) -} - -// TestMigrateActions_NoMatch verifies no-op when no actions reference legacy address. -func TestMigrateActions_NoMatch(t *testing.T) { - f := initMockFixture(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 - }) + }).Times(1) err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) require.NoError(t, err) } -// --- MigrateClaim tests --- - -// TestMigrateClaim_Found verifies that the claim record's DestAddress is updated. -func TestMigrateClaim_Found(t *testing.T) { +// 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() - record := claimtypes.ClaimRecord{ - OldAddress: "pastel1legacyoldaddress", - DestAddress: legacy.String(), + action := &actiontypes.Action{ + ActionID: "action-2", + Creator: creator.String(), + SuperNodes: []string{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) + 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.MigrateClaim(f.ctx, legacy, newAddr) + err := f.keeper.MigrateActions(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) { +// TestMigrateActions_NoMatch verifies no-op when no actions reference legacy address. +func TestMigrateActions_NoMatch(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 - }) + 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.MigrateClaim(f.ctx, legacy, newAddr) + err := f.keeper.MigrateActions(f.ctx, legacy, newAddr) require.NoError(t, err) } @@ -1176,6 +1256,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 @@ -1191,6 +1275,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) @@ -1285,6 +1422,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 @@ -1343,6 +1484,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 @@ -1432,8 +1577,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{ @@ -1449,9 +1593,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 { @@ -1494,6 +1635,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)) @@ -1522,8 +1665,661 @@ 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) +} + +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) + + 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) + + // 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) + 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) + + 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) + + // 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) +} + +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.writeRedelegationIndexes(delegator, oldValAddr, dstValAddr) + + // 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") +} + +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) +} + +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) + + // 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) + 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) +} + +// 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() + + 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) +} + +// 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 --- diff --git a/x/evmigration/keeper/migrate_validator.go b/x/evmigration/keeper/migrate_validator.go index c00cfb39..a79cbeb2 100644 --- a/x/evmigration/keeper/migrate_validator.go +++ b/x/evmigration/keeper/migrate_validator.go @@ -61,24 +61,42 @@ 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 + var val stakingtypes.Validator if len(delegations) > 0 { currentRewards, err := k.distributionKeeper.GetValidatorCurrentRewards(ctx, newValAddr) if err != nil { 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 + } + // 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 } } @@ -106,25 +124,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 + Stake: val.TokensFromSharesTruncated(del.Shares), } 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,14 +166,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. - 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) + if reds == nil { + var err error + reds, err = k.redelegationsForValidator(ctx, oldValAddr) + if err != nil { + return err } - return false - }); err != nil { - return err } for _, red := range reds { @@ -236,17 +246,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 +258,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..463bc00a --- /dev/null +++ b/x/evmigration/keeper/migrate_validator_scoped.go @@ -0,0 +1,188 @@ +package keeper + +import ( + "fmt" + "sort" + + 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 + } + + // 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 redKey := range seen { + keys = append(keys, redKey) + } + sort.Strings(keys) + reds := make([]stakingtypes.Redelegation, 0, len(seen)) + for _, redKey := range keys { + reds = append(reds, seen[redKey]) + } + 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 func() { _ = 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 func() { _ = 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 +} + +// 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 + 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 func() { _ = 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.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 a0cbb0a5..746f9468 100644 --- a/x/evmigration/keeper/msg_server_claim_legacy_test.go +++ b/x/evmigration/keeper/msg_server_claim_legacy_test.go @@ -106,14 +106,21 @@ 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()) 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) @@ -130,13 +137,12 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { feegrantKeeper, supernodeKeeper, actionKeeper, - 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 +153,8 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { mf := &mockFixture{ ctx: ctx, keeper: k, + cdc: encCfg.Codec, + stakingStore: stakingStoreService, accountKeeper: accountKeeper, bankKeeper: bankKeeper, stakingKeeper: stakingKeeper, @@ -155,7 +163,6 @@ func initMsgServerFixture(t *testing.T) *msgServerFixture { feegrantKeeper: feegrantKeeper, supernodeKeeper: supernodeKeeper, actionKeeper: actionKeeper, - claimKeeper: claimKeeper, } return &msgServerFixture{ @@ -476,10 +483,8 @@ func TestClaimLegacyAccount_Success(t *testing.T) { ) // Step 7: MigrateActions — no matching actions. - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) - - // Step 8: MigrateClaim — no claim records targeting this address. - f.claimKeeper.EXPECT().IterateClaimRecords(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) msg := newClaimMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) @@ -566,14 +571,14 @@ 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( sntypes.SuperNode{}, false, nil, ) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) - f.claimKeeper.EXPECT().IterateClaimRecords(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) msg := &types.MsgClaimLegacyAccount{ LegacyAddress: legacyAddr.String(), @@ -855,8 +860,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) @@ -865,52 +870,12 @@ 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().IterateActions(gomock.Any(), gomock.Any()).Return(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 // 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() @@ -933,10 +898,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().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) + f.stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(gomock.Any(), oldValAddr).Return(ubds, nil) msg := newValidatorMigrationMsg(t, privKey, legacyAddr, newPrivKey, newAddr) @@ -992,10 +958,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) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(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 @@ -1057,7 +1023,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) @@ -1086,9 +1061,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) @@ -1137,8 +1112,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) @@ -1158,7 +1133,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. @@ -1225,6 +1201,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 @@ -1248,14 +1228,14 @@ 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( sntypes.SuperNode{}, false, nil, ) - f.actionKeeper.EXPECT().IterateActions(gomock.Any(), gomock.Any()).Return(nil) - f.claimKeeper.EXPECT().IterateClaimRecords(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) 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 9f209f78..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 } @@ -87,16 +98,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, @@ -180,7 +186,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) } @@ -196,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. @@ -244,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 169139c4..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) @@ -134,7 +137,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 +235,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) @@ -228,29 +285,30 @@ 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) + // 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) 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) - 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) // 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. @@ -288,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) @@ -354,7 +409,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) @@ -389,34 +443,32 @@ 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) }, ) 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) - 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) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) // V5: no supernode. 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 @@ -463,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) @@ -483,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) @@ -547,10 +602,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) - f.stakingKeeper.EXPECT().IterateRedelegations(gomock.Any(), gomock.Any()).Return(nil) + // 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) @@ -593,8 +647,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) @@ -604,27 +659,26 @@ 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) 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) } // 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) // 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. @@ -651,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 777f5527..7fceb5a1 100644 --- a/x/evmigration/keeper/query.go +++ b/x/evmigration/keeper/query.go @@ -180,15 +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). - 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 + // (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 @@ -212,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 55a7fa5d..cf9361bf 100644 --- a/x/evmigration/keeper/query_test.go +++ b/x/evmigration/keeper/query_test.go @@ -312,6 +312,187 @@ 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) +} + +// 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 5e757878..c86c1e74 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. @@ -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 } @@ -309,25 +308,11 @@ 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) { +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 } @@ -339,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 } @@ -354,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 } @@ -369,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 } @@ -384,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 } @@ -399,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 } @@ -414,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) @@ -428,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) @@ -441,8 +426,22 @@ 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, types3.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 { +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) @@ -1247,98 +1246,60 @@ func (mr *MockActionKeeperMockRecorder) GetActionByID(ctx, actionID any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionByID", reflect.TypeOf((*MockActionKeeper)(nil).GetActionByID), ctx, actionID) } -// IterateActions mocks base method. -func (m *MockActionKeeper) IterateActions(ctx types2.Context, handler func(*types.Action) bool) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IterateActions", ctx, handler) - ret0, _ := ret[0].(error) - return ret0 -} - -// IterateActions indicates an expected call of IterateActions. -func (mr *MockActionKeeperMockRecorder) IterateActions(ctx, handler any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateActions", reflect.TypeOf((*MockActionKeeper)(nil).IterateActions), ctx, handler) -} - -// SetAction mocks base method. -func (m *MockActionKeeper) SetAction(ctx types2.Context, action *types.Action) error { +// GetActionsByCreator mocks base method. +func (m *MockActionKeeper) GetActionsByCreator(ctx types1.Context, creator string) ([]*types.Action, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetAction", ctx, action) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "GetActionsByCreator", ctx, creator) + ret0, _ := ret[0].([]*types.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// SetAction indicates an expected call of SetAction. -func (mr *MockActionKeeperMockRecorder) SetAction(ctx, action any) *gomock.Call { +// 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, "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 + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionsByCreator", reflect.TypeOf((*MockActionKeeper)(nil).GetActionsByCreator), ctx, creator) } -// 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) { +// GetActionsBySuperNode mocks base method. +func (m *MockActionKeeper) GetActionsBySuperNode(ctx types1.Context, supernode string) ([]*types.Action, 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 + ret := m.ctrl.Call(m, "GetActionsBySuperNode", ctx, supernode) + ret0, _ := ret[0].([]*types.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// GetClaimRecord indicates an expected call of GetClaimRecord. -func (mr *MockClaimKeeperMockRecorder) GetClaimRecord(ctx, arg1 any) *gomock.Call { +// 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, "GetClaimRecord", reflect.TypeOf((*MockClaimKeeper)(nil).GetClaimRecord), ctx, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActionsBySuperNode", reflect.TypeOf((*MockActionKeeper)(nil).GetActionsBySuperNode), ctx, supernode) } -// IterateClaimRecords mocks base method. -func (m *MockClaimKeeper) IterateClaimRecords(ctx types2.Context, cb func(types0.ClaimRecord) (bool, error)) error { +// IterateActions mocks base method. +func (m *MockActionKeeper) IterateActions(ctx types1.Context, handler func(*types.Action) bool) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IterateClaimRecords", ctx, cb) + ret := m.ctrl.Call(m, "IterateActions", ctx, handler) ret0, _ := ret[0].(error) return ret0 } -// IterateClaimRecords indicates an expected call of IterateClaimRecords. -func (mr *MockClaimKeeperMockRecorder) IterateClaimRecords(ctx, cb any) *gomock.Call { +// IterateActions indicates an expected call of IterateActions. +func (mr *MockActionKeeperMockRecorder) IterateActions(ctx, handler any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateClaimRecords", reflect.TypeOf((*MockClaimKeeper)(nil).IterateClaimRecords), ctx, cb) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IterateActions", reflect.TypeOf((*MockActionKeeper)(nil).IterateActions), ctx, handler) } -// SetClaimRecord mocks base method. -func (m *MockClaimKeeper) SetClaimRecord(ctx types2.Context, claimRecord types0.ClaimRecord) error { +// SetAction mocks base method. +func (m *MockActionKeeper) SetAction(ctx types1.Context, action *types.Action) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetClaimRecord", ctx, claimRecord) + ret := m.ctrl.Call(m, "SetAction", ctx, action) ret0, _ := ret[0].(error) return ret0 } -// SetClaimRecord indicates an expected call of SetClaimRecord. -func (mr *MockClaimKeeperMockRecorder) SetClaimRecord(ctx, claimRecord any) *gomock.Call { +// SetAction indicates an expected call of SetAction. +func (mr *MockActionKeeperMockRecorder) SetAction(ctx, action any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetClaimRecord", reflect.TypeOf((*MockClaimKeeper)(nil).SetClaimRecord), ctx, claimRecord) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAction", reflect.TypeOf((*MockActionKeeper)(nil).SetAction), ctx, action) } 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 7955cdc0..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" ) @@ -133,13 +132,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) } - -// 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 -}