Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f2ea48f
scripts: add chain-helper network defaults
akobrin1 Jul 2, 2026
a3a4b75
fix evmigration validator scoped iteration
akobrin1 Jul 3, 2026
0836de5
evmigration: cut double-fetch and O(H) refcount scans in validator mi…
akobrin1 Jul 3, 2026
fb32f5b
evmigration: sort scoped redelegations for deterministic replay
akobrin1 Jul 3, 2026
75a6559
evmigration: test deterministic redelegation replay order
akobrin1 Jul 3, 2026
ef81ba0
docs(evm): inventory scoped-migration tests; fix keeper comments
akobrin1 Jul 3, 2026
4a555b2
docs: clarify migration wallet usage
akobrin1 Jul 3, 2026
ebb8b11
test: cover chain-helper network defaults
akobrin1 Jul 3, 2026
dd9773c
fix(devnet): move lumera uploader grpc default port
akobrin1 Jul 3, 2026
8e3a740
Merge branch 'codex/chain-helper-network-defaults' into fix-migrate-v…
akobrin1 Jul 3, 2026
384d152
feat(upgrades): add v1.20.1 handler
akobrin1 Jul 3, 2026
7ef11c8
refactor(upgrades): use standard handler for v1.20.1
akobrin1 Jul 3, 2026
816c1d8
evmigration: address PR review — fail-loud estimate, env precedence, …
akobrin1 Jul 3, 2026
6192eff
fix(evmigration): scope MigrateActions via action secondary indexes
akobrin1 Jul 3, 2026
9790141
fix(evmigration): store delegation stake as tokens, not shares
akobrin1 Jul 3, 2026
5a72b21
Skip claim records in evmigration devnet verify
akobrin1 Jul 4, 2026
e2b75c2
fix(evmigration): allow Unbonded (non-jailed) validators to migrate
akobrin1 Jul 4, 2026
768cd94
test(evmigration): add validator-migration scaling test + benchmark
akobrin1 Jul 4, 2026
794af6e
docs: add v1.20.1 changelog entry
akobrin1 Jul 4, 2026
6e6a5a4
docs(evmigration): present validator-migration scaling benchmark resu…
akobrin1 Jul 4, 2026
9b10a15
fix(upgrades): skip v1.20.0 on mainnet, run EVM bring-up via v1.20.1
akobrin1 Jul 5, 2026
e7b027f
fix(upgrades): route by genesis chain id
akobrin1 Jul 5, 2026
fddf4cd
Merge remote-tracking branch 'origin/master' into fix-migrate-validat…
akobrin1 Jul 6, 2026
8c1c1a7
devnet: wire v1.20.1 upgrade target and release binaries
akobrin1 Jul 6, 2026
9c853de
docs(design): state-driven v1.20.1 EVM bring-up
akobrin1 Jul 6, 2026
72c5ff9
feat(upgrades): state-driven v1.20.1 EVM bring-up
akobrin1 Jul 6, 2026
d7c3084
fix(upgrades): harden state-driven v1.20.1 per review
akobrin1 Jul 6, 2026
a028184
devnet: target v1.20.1 in scripted evm-upgrade flow
akobrin1 Jul 6, 2026
ca8224b
chore(devnet): tidy module deps after x/net dependabot bump
akobrin1 Jul 6, 2026
400e8ea
test(evmigration): correct stale "orphaned validator" comments
akobrin1 Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
19 changes: 8 additions & 11 deletions Makefile.devnet
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -697,12 +697,9 @@ devnet-upgrade-1120:
# Special case: upgrade to the locally-built version (devnet/bin, not
# devnet/bin-<VERSION>). 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
Expand All @@ -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; \
Expand Down Expand Up @@ -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; \
Expand Down
67 changes: 64 additions & 3 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"

Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ****/

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions app/upgrade_chain_id_test.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions app/upgrades/add_only_store_loader_test.go
Original file line number Diff line number Diff line change
@@ -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")))
}
Loading
Loading