From 2dc8215c7d3ceef25d6701e2a8aba2a550eebefd Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Wed, 10 Jun 2026 14:31:27 -0400 Subject: [PATCH 1/5] feat(evmigration): tune migration params, fix chain-helper counts - raise DefaultMaxValidatorDelegations 2000 -> 2500, sized against the live mainnet worst-case migration-object count of 1,632 (delegations + unbondings + redelegations) - v1.20.0 upgrade handler auto-sets devnet migration_end_time to upgrade block time + 2 days; testnet/mainnet keep 0 (specific absolute timestamp chosen near launch) - move chain-ID detection (mainnet/testnet/devnet prefixes) into config as a single source of truth; app/upgrades/chain_id.go now delegates, avoiding an import cycle for the v1_20_0 subpackage - wire EvmigrationKeeper into AppUpgradeParams and app.setupUpgrades - fix scripts/chain-helper.sh max-validator-delegations: use --page-count-total for exact delegation/unbonding counts (previously truncated at the page limit, under-reporting large validators), add per-validator count= progress, default buffer 10% -> 30% - add scripts/chain-helper.sh stats: accounts, validators + jailed, and supernodes grouped by current state - update rollout doc and tests for the finalized evmigration decisions Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app.go | 1 + app/upgrades/chain_id.go | 16 +- app/upgrades/params/params.go | 2 + app/upgrades/v1_20_0/upgrade.go | 33 ++++ app/upgrades/v1_20_0/upgrade_test.go | 60 ++++++ config/config.go | 25 +++ docs/evm-integration/architecture/rollout.md | 44 ++--- scripts/chain-helper.sh | 195 ++++++++++++++++++- tests/scripts/chain-helper.bats | 91 ++++++++- x/evmigration/types/params.go | 7 +- 10 files changed, 421 insertions(+), 53 deletions(-) diff --git a/app/app.go b/app/app.go index e73a65b1..c7938745 100644 --- a/app/app.go +++ b/app/app.go @@ -502,6 +502,7 @@ func (app *App) setupUpgrades() { FeeMarketKeeper: &app.FeeMarketKeeper, Erc20Keeper: &app.Erc20Keeper, Erc20StoreKey: app.GetKey(erc20types.StoreKey), + EvmigrationKeeper: &app.EvmigrationKeeper, } allUpgrades := upgrades.AllUpgrades(params) diff --git a/app/upgrades/chain_id.go b/app/upgrades/chain_id.go index e424db83..a7a98d1f 100644 --- a/app/upgrades/chain_id.go +++ b/app/upgrades/chain_id.go @@ -1,24 +1,22 @@ package upgrades -import "strings" +import lcfg "github.com/LumeraProtocol/lumera/config" -const ( - chainPrefixMainnet = "lumera-mainnet" - chainPrefixTestnet = "lumera-testnet" - chainPrefixDevnet = "lumera-devnet" -) +// These thin wrappers delegate to the canonical chain-ID detection in the +// config package so the prefixes live in a single leaf package that upgrade +// version subpackages (e.g. v1_20_0) can import without an import cycle. // IsMainnet returns true if the chain ID corresponds to mainnet. func IsMainnet(chainID string) bool { - return strings.HasPrefix(chainID, chainPrefixMainnet) + return lcfg.IsMainnetChainID(chainID) } // IsTestnet returns true if the chain ID corresponds to testnet. func IsTestnet(chainID string) bool { - return strings.HasPrefix(chainID, chainPrefixTestnet) + return lcfg.IsTestnetChainID(chainID) } // IsDevnet returns true if the chain ID corresponds to devnet. func IsDevnet(chainID string) bool { - return strings.HasPrefix(chainID, chainPrefixDevnet) + return lcfg.IsDevnetChainID(chainID) } diff --git a/app/upgrades/params/params.go b/app/upgrades/params/params.go index 448765ab..b0067746 100644 --- a/app/upgrades/params/params.go +++ b/app/upgrades/params/params.go @@ -13,6 +13,7 @@ import ( actionmodulekeeper "github.com/LumeraProtocol/lumera/x/action/v1/keeper" auditmodulekeeper "github.com/LumeraProtocol/lumera/x/audit/v1/keeper" + evmigrationmodulekeeper "github.com/LumeraProtocol/lumera/x/evmigration/keeper" sntypes "github.com/LumeraProtocol/lumera/x/supernode/v1/types" ) @@ -36,4 +37,5 @@ type AppUpgradeParams struct { FeeMarketKeeper *feemarketkeeper.Keeper Erc20Keeper *erc20keeper.Keeper Erc20StoreKey *storetypes.KVStoreKey + EvmigrationKeeper *evmigrationmodulekeeper.Keeper } diff --git a/app/upgrades/v1_20_0/upgrade.go b/app/upgrades/v1_20_0/upgrade.go index 5bb06571..3d92e3b8 100644 --- a/app/upgrades/v1_20_0/upgrade.go +++ b/app/upgrades/v1_20_0/upgrade.go @@ -3,6 +3,7 @@ package v1_20_0 import ( "context" "fmt" + "time" "cosmossdk.io/store/prefix" storetypes "cosmossdk.io/store/types" @@ -25,6 +26,13 @@ import ( // UpgradeName is the on-chain name used for this upgrade. const UpgradeName = "v1.20.0" +// devnetMigrationWindow is how long after the upgrade block the migration +// window stays open on devnet. Devnet derives a finite migration_end_time +// automatically so rehearsals exercise a real deadline; testnet and mainnet +// instead receive a specific absolute migration_end_time chosen near their +// own upgrade. +const devnetMigrationWindow = 2 * 24 * time.Hour + // StoreUpgrades declares store additions for this upgrade. var StoreUpgrades = storetypes.StoreUpgrades{ Added: []string{ @@ -129,6 +137,31 @@ func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHand } } + // On devnet, derive a finite migration_end_time from the upgrade block + // time so rehearsals run against a real deadline without hardcoding an + // absolute timestamp. RunMigrations already seeded the evmigration module + // with default params (enable_migration=true, migration_end_time=0); here + // we only override the deadline. Testnet and mainnet keep the default 0 + // at upgrade and receive a specific migration_end_time chosen separately. + if lcfg.IsDevnetChainID(p.ChainID) { + if p.EvmigrationKeeper == nil { + return nil, fmt.Errorf("%s devnet upgrade requires evmigration keeper to be wired", UpgradeName) + } + emParams, err := p.EvmigrationKeeper.Params.Get(ctx) + if err != nil { + return nil, fmt.Errorf("get evmigration params: %w", err) + } + emParams.MigrationEndTime = ctx.BlockTime().Add(devnetMigrationWindow).Unix() + if err := p.EvmigrationKeeper.Params.Set(ctx, emParams); err != nil { + return nil, fmt.Errorf("set devnet evmigration migration_end_time: %w", err) + } + p.Logger.Info("Set devnet migration_end_time", + "chain_id", p.ChainID, + "migration_end_time", emParams.MigrationEndTime, + "window", devnetMigrationWindow.String(), + ) + } + p.Logger.Info(fmt.Sprintf("Successfully completed upgrade %s", UpgradeName)) return newVM, nil } diff --git a/app/upgrades/v1_20_0/upgrade_test.go b/app/upgrades/v1_20_0/upgrade_test.go index a404e661..46c9cf0f 100644 --- a/app/upgrades/v1_20_0/upgrade_test.go +++ b/app/upgrades/v1_20_0/upgrade_test.go @@ -2,6 +2,7 @@ package v1_20_0_test import ( "testing" + "time" "cosmossdk.io/log" "cosmossdk.io/store/prefix" @@ -18,6 +19,65 @@ import ( erc20policytypes "github.com/LumeraProtocol/lumera/x/erc20policy/types" ) +// upgradeParamsForChain returns AppUpgradeParams wired with the app keepers the +// v1.20.0 handler needs, for the given chain ID. +func upgradeParamsForChain(app *lumeraapp.App, chainID string) appParams.AppUpgradeParams { + return appParams.AppUpgradeParams{ + ChainID: chainID, + 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, + } +} + +// On devnet the handler derives a finite migration_end_time from the upgrade +// block time (block time + 2 days) so rehearsals run against a real deadline. +func TestV1200SetsDevnetMigrationEndTime(t *testing.T) { + app := lumeraapp.Setup(t) + ctx := app.BaseApp.NewContext(false) + + // Default genesis params seed migration with no deadline. + before, err := app.EvmigrationKeeper.Params.Get(ctx) + require.NoError(t, err) + require.Equal(t, int64(0), before.MigrationEndTime) + + want := ctx.BlockTime().Add(2 * 24 * time.Hour).Unix() + + handler := upgradev1200.CreateUpgradeHandler(upgradeParamsForChain(app, "lumera-devnet-1")) + _, err = handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + require.NoError(t, err) + + after, err := app.EvmigrationKeeper.Params.Get(ctx) + require.NoError(t, err) + require.Equal(t, want, after.MigrationEndTime, + "devnet upgrade should set migration_end_time to upgrade block time + 2 days") + require.True(t, after.EnableMigration, "enable_migration should remain true (immediate-open)") + require.Equal(t, uint64(2500), after.MaxValidatorDelegations, + "max_validator_delegations default should be 2500") +} + +// Testnet and mainnet keep migration_end_time at the default 0 at upgrade; a +// specific absolute timestamp is chosen for them separately. +func TestV1200LeavesMigrationEndTimeZeroOffDevnet(t *testing.T) { + app := lumeraapp.Setup(t) + ctx := app.BaseApp.NewContext(false) + + handler := upgradev1200.CreateUpgradeHandler(upgradeParamsForChain(app, "lumera-testnet-1")) + _, err := handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + require.NoError(t, err) + + after, err := app.EvmigrationKeeper.Params.Get(ctx) + require.NoError(t, err) + require.Equal(t, int64(0), after.MigrationEndTime, + "non-devnet upgrade must leave migration_end_time at the default 0") +} + func TestV1200InitializesERC20ParamsWhenInitGenesisIsSkipped(t *testing.T) { app := lumeraapp.Setup(t) ctx := app.BaseApp.NewContext(false) diff --git a/config/config.go b/config/config.go index 3e23b26a..81c08696 100644 --- a/config/config.go +++ b/config/config.go @@ -1,9 +1,34 @@ package config import ( + "strings" + sdk "github.com/cosmos/cosmos-sdk/types" ) +// Chain-ID prefixes identifying each Lumera network. Chain IDs are of the form +// "-" (e.g. "lumera-mainnet-1", "lumera-devnet-3"). +const ( + MainnetChainIDPrefix = "lumera-mainnet" + TestnetChainIDPrefix = "lumera-testnet" + DevnetChainIDPrefix = "lumera-devnet" +) + +// IsMainnetChainID reports whether chainID belongs to a Lumera mainnet network. +func IsMainnetChainID(chainID string) bool { + return strings.HasPrefix(chainID, MainnetChainIDPrefix) +} + +// IsTestnetChainID reports whether chainID belongs to a Lumera testnet network. +func IsTestnetChainID(chainID string) bool { + return strings.HasPrefix(chainID, TestnetChainIDPrefix) +} + +// IsDevnetChainID reports whether chainID belongs to a Lumera devnet network. +func IsDevnetChainID(chainID string) bool { + return strings.HasPrefix(chainID, DevnetChainIDPrefix) +} + const ( // DefaultMaxIBCCallbackGas is the default value of maximum gas that an IBC callback can use. // If the callback uses more gas, it will be out of gas and the contract state changes will be reverted, diff --git a/docs/evm-integration/architecture/rollout.md b/docs/evm-integration/architecture/rollout.md index 579fd322..9a46fe9c 100644 --- a/docs/evm-integration/architecture/rollout.md +++ b/docs/evm-integration/architecture/rollout.md @@ -71,22 +71,6 @@ This means rollout work is now primarily operational: release qualification, sta - [External Block Explorer Integration Plan](../guides/block-explorer.md) — block explorer rollout on testnet and mainnet - [CosmWasm Cross-Runtime Bridge — Wasm Precompile & EVM Plugin](../precompiles/wasm-precompile.md) — bidirectional CosmWasm↔EVM bridge behavior and test targets -## Roles and Ownership - -The rollout needs named role ownership even if the actual people are assigned later. - -| Responsibility | Owner role | Notes | -| --- | --- | --- | -| stage go/no-go decision | Release lead | decides whether to promote from RC to devnet, devnet to testnet, and testnet to mainnet | -| governance proposal prep and timing | Governance owner | owns proposal content, deposit, timing, voting tracking, and contingency resubmission | -| validator coordination | Validator operations owner | owns validator comms, halt instructions, restart coordination, and readiness tracking | -| migration rehearsal and Portal flow | Migration owner | owns migration runbooks, Portal flow, Keplr / MetaMask migration tests | -| RPC / infra / explorer readiness | Infrastructure owner | owns RPC health, OpenRPC, rate limiting, block explorer rollout, and monitoring | -| wallet and ecosystem partner readiness | Ecosystem owner | owns chain registry, wallet partners, exchanges, custodians, and explorer contacts | -| public announcements and status updates | Communications owner | owns public announcement copy, cadence, and incident updates | -| incident command during upgrade day | Incident commander | single coordinator for halt / hold / resume instructions | -| migration-window support and triage | Support owner | owns inbound support flow, FAQ updates, and escalation during migration window | - ## Communication Channels Before testnet, Lumera should map each audience to a concrete channel, not just a message. @@ -121,6 +105,20 @@ Before tagging the `v1.20.0` EVM release, the release owner must fill this table The current code seeds most of these from `config/evm.go`, `app/evm/genesis.go`, and `app/upgrades/v1_20_0/upgrade.go`. That makes them easy to overlook: they may not require a governance parameter proposal, but they still become live network behavior at upgrade height. +#### Parameters That Actually Require a Decision + +Most of the detailed tables in the following subsections are **confirm-and-verify only**: their values are hardcoded in `config/evm.go` or seeded deterministically by the `v1_20_0` upgrade handler, so they cannot drift unless the code changes. The EVM chain ID (`76857769`), gas denom (`ulume`), extended denom (`alume`), EVM coin info, the fee-market constants, `block.max_gas`, the ERC20 toggles, and the precompile set all fall into this group — for mainnet they only need a final on-chain check against the values below, not a fresh decision. + +The short list that genuinely needs a value chosen before tagging is the `x/evmigration` policy. The recommendations below were tuned against the live `lumera-mainnet-1` chain (height `5,462,371`, queried `2026-06-10`) using `scripts/chain-helper.sh`: **160,021** accounts, **50** bonded validators (cap 50) out of **83** total (9 jailed), a worst-case validator **migration-object count of 1,632** (the largest validator's 1,593 delegations + 29 unbonding delegations + redelegations — exactly what the cap bounds), a 21-day unbonding period, and a 7-day governance voting period. Re-run `scripts/chain-helper.sh max-validator-delegations` and `scripts/chain-helper.sh stats` close to tag time, since these counts grow with the chain. + +| Parameter | Code default | Live mainnet signal | Decision | +| --- | --- | --- | --- | +| `enable_migration` | `true` (immediate-open) | — | **Keep `true` — immediate-open.** Migration opens as soon as the upgraded chain produces blocks; the upgrade handler leaves `enable_migration=true` with no controlled-open gating. Public messaging, support, and RPC capacity must therefore be live before validators restart. | +| `migration_end_time` | `0` (no deadline) | 160,021 accounts and 83 validators to migrate | **Devnet: auto-set by the `v1.20.0` upgrade handler** to upgrade-block time + **2 days** (no hardcoded timestamp). **Testnet and mainnet:** the handler leaves it `0`; set a specific absolute Unix timestamp (seconds) — a chosen wall-clock end time, ~**+120 days** past the planned upgrade — closer to launch. Must be non-zero before mainnet while the proof format has no expiry. | +| `max_validator_delegations` | `2500` (raised from 2000) | worst-case validator migration-object count is **1,632** (`chain-helper.sh` reports `max_observed: 1632`, `suggested_cap: 2122` at the default 30% buffer) | **Shipped default is now `2500`** — clears the worst case (1,632) with ~1.53× headroom; `3000` judged over-provisioned given the short runway. Re-check with `chain-helper.sh` near tag time. | +| `max_migrations_per_block` | `50` | at 50/block (~5s blocks) the full 160k account base clears in ~11 days of continuous saturation | **Keep `50`.** Adequate for a weeks-long window; only revisit if load tests show RPC or block-production stress. | +| `max_multisig_sub_keys` | `20` | not enumerable from public state — multisig pubkeys are only revealed on-chain after the group's first tx | **Keep `20`** unless the internal key inventory knows of a larger foundation/treasury multisig; confirm against that inventory rather than chain state. | + #### Chain Identity, Denom, and VM Params | Parameter / decision | Current code default | Required decision before tag | Why it matters | Verification | @@ -157,11 +155,11 @@ The current code seeds most of these from `config/evm.go`, `app/evm/genesis.go`, | Parameter / decision | Current code default | Required decision before tag | Why it matters | Verification | | --- | --- | --- | --- | --- | -| `enable_migration` | `true` | decide immediate-open vs controlled-open and ensure first post-upgrade state matches that decision | if `true`, migration can open as soon as the upgraded chain produces blocks | `lumerad q evmigration params` immediately after upgrade | -| `migration_end_time` | `0` meaning no deadline | set a finite mainnet deadline if the no-expiry proof format remains; otherwise explicitly document why no deadline is acceptable | an unlimited window extends support and proof-replay risk indefinitely | query params and compare the Unix timestamp to the public migration-window announcement | -| `max_migrations_per_block` | `50` | decide the per-block claim throttle from RC/devnet load tests | too high can stress blocks/RPC; too low can create user backlog | run migration traffic at the proposed limit alongside normal Cosmos/EVM traffic | -| `max_validator_delegations` | `2000` | calculate mainnet validator maximum and decide a cap with buffer | validator migrations iterate delegations, unbondings, and redelegations; under-sizing blocks large validators, over-sizing increases per-tx work | run `scripts/chain-helper.sh max-validator-delegations --json` once the exact `migration-estimate` query is available, then choose the cap from the observed maximum plus buffer | -| `max_multisig_sub_keys` | `20` | decide the maximum supported multisig size for launch | larger values increase proof size, gas, and coordinator complexity | rehearse multisig migrations at or near the proposed ceiling and verify ante / keeper rejection above it | +| `enable_migration` | `true` | **Decided: immediate-open.** Keep `enable_migration=true`; the upgrade handler leaves it enabled so migration opens at the first post-upgrade block | if `true`, migration can open as soon as the upgraded chain produces blocks | `lumerad q evmigration params` immediately after upgrade | +| `migration_end_time` | `0` meaning no deadline | **Decided:** devnet auto-set by the upgrade handler to upgrade-block time + 2 days; testnet/mainnet left `0` by the handler and given a specific absolute Unix timestamp (~+120 days) chosen near launch | an unlimited window extends support and proof-replay risk indefinitely | query params and compare the Unix timestamp to the public migration-window announcement | +| `max_migrations_per_block` | `50` | **Decided: keep `50`** — adequate per-block claim throttle; revisit only if RC/devnet load tests show stress | too high can stress blocks/RPC; too low can create user backlog | run migration traffic at the proposed limit alongside normal Cosmos/EVM traffic | +| `max_validator_delegations` | `2500` | **Decided & shipped: `2500`** (raised from 2000) — clears the live worst case of 1,632 migration objects (`chain-helper.sh` `max_observed: 1632`) with ~1.53× headroom; `3000` judged over-provisioned given the short runway | validator migrations iterate delegations, unbondings, and redelegations; under-sizing blocks large validators, over-sizing increases per-tx work | run `scripts/chain-helper.sh max-validator-delegations --json` (works today in `staking-pre-evm` mode) and confirm the chosen cap exceeds the observed maximum plus buffer | +| `max_multisig_sub_keys` | `20` | **Decided: keep `20`** — confirm against the internal key inventory rather than chain state | larger values increase proof size, gas, and coordinator complexity | rehearse multisig migrations at or near the proposed ceiling and verify ante / keeper rejection above it | Tagging should not proceed until the release notes contain the final intended values for every row above, and the RC evidence shows the upgrade handler initializes those values without relying on an after-the-fact governance change. @@ -174,7 +172,9 @@ Before testnet and mainnet, choose one of these activation policies and test tha - **Immediate-open policy**: migration is available immediately after the upgrade. This is operationally simpler, but public messaging, support staffing, RPC capacity, and migration monitoring must be live before validators restart. - **Controlled-open policy**: migration remains disabled until post-upgrade smoke tests pass. This requires an implementation or governance path that is already effective at upgrade time; a normal post-upgrade parameter proposal is too slow to prevent an initial open window. -Mainnet should not rely on an assumed manual "open the window" step unless the release candidate proves that `enable_migration=false` or the intended `migration_end_time` is already in state at the first post-upgrade block. +**Decision:** Lumera uses the **immediate-open policy** — `enable_migration=true` at the first post-upgrade block — paired with a finite `migration_end_time` (a specific absolute Unix timestamp in seconds, compared against block time). On **devnet** the `v1.20.0` upgrade handler sets this automatically to upgrade-block time + **2 days**. On **testnet and mainnet** the handler leaves it `0`; a specific absolute timestamp (~**120 days** past the upgrade) is chosen and applied near launch. Because migration opens immediately, public messaging, support staffing, RPC capacity, and migration monitoring must be live before validators restart. + +Mainnet should not rely on an assumed manual "open the window" step; the release candidate must prove that `enable_migration=true` and the intended `migration_end_time` are already in state at the first post-upgrade block. ## Failure Modes and Mitigations diff --git a/scripts/chain-helper.sh b/scripts/chain-helper.sh index 68ad0613..fb72169d 100755 --- a/scripts/chain-helper.sh +++ b/scripts/chain-helper.sh @@ -13,7 +13,7 @@ GRPC="${LUMERA_GRPC:-$DEFAULT_GRPC}" BIN="${LUMERA_BINARY:-lumerad}" GRPCURL="${LUMERA_GRPCURL_BINARY:-grpcurl}" LIMIT="${LUMERA_QUERY_LIMIT:-1000}" -BUFFER_PERCENT="${LUMERA_BUFFER_PERCENT:-10}" +BUFFER_PERCENT="${LUMERA_BUFFER_PERCENT:-30}" JSON_OUTPUT=0 ALLOW_PARTIAL=0 GRPC_INSECURE="${LUMERA_GRPC_INSECURE:-0}" @@ -28,6 +28,9 @@ Commands: max-validator-delegations Calculate the highest validator migration object count and recommend a max_validator_delegations value. + stats + Show chain-wide counts: total accounts, total/jailed validators, and + total supernodes grouped by their current state. Common flags: --node, --rpc Tendermint RPC endpoint @@ -43,7 +46,7 @@ Common flags: -h, --help show this help max-validator-delegations flags: - --buffer-percent integer percent buffer for suggested_cap; default: 10 + --buffer-percent integer percent buffer for suggested_cap; default: 30 --allow-partial allow staking-only fallback if evmigration migration-estimate is unavailable @@ -129,16 +132,43 @@ estimate_counts_tsv() { ' } +# staking_count_total
+# Returns the exact total number of records for a staking sub-query using the +# node-side count_total. Counting (.records | length) on a single page silently +# truncates any validator that has more delegations than --page-limit (e.g. a +# validator with 1593 delegations reads as 1000 under the default limit), which +# is unsafe for sizing max_validator_delegations. +staking_count_total() { + local subcmd="$1" + local addr="$2" + local out total page_len + out="$(lumerad_query staking "$subcmd" "$addr" --page-limit 1 --page-count-total)" || + die 3 "staking $subcmd query failed for $addr" + total="$(jq -r '(.pagination.total // "") | tostring' <<<"$out")" + if [[ "$total" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$total" + return + fi + # Nodes omit pagination.total for an empty result set, so an empty page means + # zero records. A non-empty page without a total means count_total is not + # supported and we would silently undercount, so fail loudly instead. + page_len="$(jq -r ' + (.delegation_responses // .delegations + // .unbonding_responses // .unbonding_delegations // []) | length + ' <<<"$out")" + if [[ "$page_len" == "0" ]]; then + printf '0\n' + else + die 3 "staking $subcmd for $addr returned no pagination.total; node may not support --page-count-total" + fi +} + delegations_to_count() { - local valoper="$1" - lumerad_query staking delegations-to "$valoper" --page-limit "$LIMIT" | - jq -r '(.delegation_responses // .delegations // []) | length' + staking_count_total delegations-to "$1" } unbonding_from_count() { - local valoper="$1" - lumerad_query staking unbonding-delegations-from "$valoper" --page-limit "$LIMIT" | - jq -r '(.unbonding_responses // .unbonding_delegations // []) | length' + staking_count_total unbonding-delegations-from "$1" } grpcurl_call() { @@ -483,11 +513,11 @@ max_validator_delegations() { for record in "${records[@]}"; do index=$((index + 1)) IFS=$'\t' read -r operator account moniker <<<"$record" - progress "counting staking records $index/$total_records: ${moniker:-$operator}" delegations="$(delegations_to_count "$operator")" unbondings="$(unbonding_from_count "$operator")" redelegations="${redelegation_counts["$operator"]:-0}" total=$((delegations + unbondings + redelegations)) + progress "counting staking records $index/$total_records: ${moniker:-$operator} count=$total (deleg=$delegations unbond=$unbondings redel=$redelegations)" rows+=("$operator"$'\t'"$account"$'\t'"$moniker"$'\t'"$delegations"$'\t'"$unbondings"$'\t'"$redelegations"$'\t'"$total"$'\t'"true") done else @@ -495,11 +525,11 @@ max_validator_delegations() { for record in "${records[@]}"; do index=$((index + 1)) IFS=$'\t' read -r operator account moniker <<<"$record" - progress "counting partial staking records $index/$total_records: ${moniker:-$operator}" delegations="$(delegations_to_count "$operator")" unbondings="$(unbonding_from_count "$operator")" redelegations="0" total=$((delegations + unbondings + redelegations)) + progress "counting partial staking records $index/$total_records: ${moniker:-$operator} count=$total (deleg=$delegations unbond=$unbondings)" rows+=("$operator"$'\t'"$account"$'\t'"$moniker"$'\t'"$delegations"$'\t'"$unbondings"$'\t'"$redelegations"$'\t'"$total"$'\t'"false") done fi @@ -527,6 +557,147 @@ max_validator_delegations() { fi } +# fetch_all +# Pages through a lumerad query, following pagination.next_key, and prints a +# single JSON array containing every element of the named top-level array +# field (e.g. "validators", "supernodes"). Avoids page-limit truncation when a +# query returns more records than a single page. +fetch_all() { + local field="$1" + shift + local page_key="" page next acc='[]' + while :; do + if [[ -n "$page_key" ]]; then + page="$(lumerad_query "$@" --page-limit "$LIMIT" --page-key "$page_key")" || + die 3 "paginated query failed: $*" + else + page="$(lumerad_query "$@" --page-limit "$LIMIT")" || + die 3 "paginated query failed: $*" + fi + acc="$(jq -c --argjson acc "$acc" --arg f "$field" '$acc + (.[$f] // [])' <<<"$page")" + next="$(jq -r '(.pagination.next_key // .pagination.nextKey // "")' <<<"$page")" + if [[ -z "$next" || "$next" == "null" ]]; then + break + fi + page_key="$next" + done + printf '%s\n' "$acc" +} + +parse_stats_flags() { + while (($#)); do + case "$1" in + --node|--rpc) + require_value "$1" "${2:-}" + NODE="$2" + shift 2 + ;; + --chain-id) + require_value "$1" "${2:-}" + CHAIN_ID="$2" + shift 2 + ;; + --binary) + require_value "$1" "${2:-}" + BIN="$2" + shift 2 + ;; + --limit) + require_value "$1" "${2:-}" + require_uint "--limit" "$2" + LIMIT="$2" + shift 2 + ;; + --json) + JSON_OUTPUT=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die 1 "unknown flag for stats: $1" + ;; + esac + done +} + +stats() { + parse_stats_flags "$@" + require_uint "LUMERA_QUERY_LIMIT" "$LIMIT" + require_tools + + progress "querying total accounts" + local accounts + accounts="$(lumerad_query auth accounts --page-limit 1 --page-count-total | + jq -r '(.pagination.total // "") | tostring')" + [[ "$accounts" =~ ^[0-9]+$ ]] || + die 3 "auth accounts returned no pagination.total; node may not support --page-count-total" + + progress "querying validators" + local validators_json validators_total validators_jailed + validators_json="$(fetch_all validators staking validators)" + validators_total="$(jq -r 'length' <<<"$validators_json")" + validators_jailed="$(jq -r '[.[] | select(.jailed == true)] | length' <<<"$validators_json")" + + progress "querying supernodes" + local supernodes_json supernodes_total supernode_states_json + supernodes_json="$(fetch_all supernodes supernode list-supernodes)" + supernodes_total="$(jq -r 'length' <<<"$supernodes_json")" + # A supernode's current status is the highest-height entry in its state history. + supernode_states_json="$(jq -c ' + [ .[] + | (.states // []) + | if length > 0 then (max_by(.height | tonumber).state) else "SUPERNODE_STATE_UNSPECIFIED" end + ] + | group_by(.) + | map({state: .[0], count: length}) + | sort_by(-.count) + ' <<<"$supernodes_json")" + + if [[ "$JSON_OUTPUT" -eq 1 ]]; then + jq -n \ + --arg command "stats" \ + --arg chain_id "$CHAIN_ID" \ + --arg rpc "$NODE" \ + --argjson accounts "$accounts" \ + --argjson validators_total "$validators_total" \ + --argjson validators_jailed "$validators_jailed" \ + --argjson supernodes_total "$supernodes_total" \ + --argjson supernode_states "$supernode_states_json" \ + '{ + command: $command, + chain_id: $chain_id, + rpc: $rpc, + accounts: { total: $accounts }, + validators: { + total: $validators_total, + jailed: $validators_jailed, + not_jailed: ($validators_total - $validators_jailed) + }, + supernodes: { + total: $supernodes_total, + by_state: $supernode_states + } + }' + else + printf 'command: stats\n' + printf 'chain_id: %s\n' "$CHAIN_ID" + printf 'rpc: %s\n' "$NODE" + printf '\n' + printf 'accounts:\n' + printf ' total: %s\n' "$accounts" + printf 'validators:\n' + printf ' total: %s\n' "$validators_total" + printf ' jailed: %s\n' "$validators_jailed" + printf ' not_jailed: %s\n' "$((validators_total - validators_jailed))" + printf 'supernodes:\n' + printf ' total: %s\n' "$supernodes_total" + jq -r '.[] | " \(.state): \(.count)"' <<<"$supernode_states_json" + fi +} + main() { if [[ "$#" -eq 0 ]]; then usage @@ -538,6 +709,10 @@ main() { shift max_validator_delegations "$@" ;; + stats) + shift + stats "$@" + ;; -h|--help|help) usage ;; diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index afe3fd1a..2782cd8b 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -37,9 +37,42 @@ if [[ "${args[0]}" == "query" && "${args[1]}" == "staking" && "${args[2]}" == "v cat <<'JSON' { "validators": [ - {"operator_address": "lumeravaloper1alpha", "description": {"moniker": "alpha"}}, - {"operator_address": "lumeravaloper1beta", "description": {"moniker": "beta"}} - ] + {"operator_address": "lumeravaloper1alpha", "description": {"moniker": "alpha"}, "jailed": false}, + {"operator_address": "lumeravaloper1beta", "description": {"moniker": "beta"}, "jailed": true} + ], + "pagination": {"total": "2"} +} +JSON + exit 0 +fi + +if [[ "${args[0]}" == "query" && "${args[1]}" == "auth" && "${args[2]}" == "accounts" ]]; then + cat <<'JSON' +{"accounts": [{}], "pagination": {"total": "160021"}} +JSON + exit 0 +fi + +if [[ "${args[0]}" == "query" && "${args[1]}" == "supernode" && "${args[2]}" == "list-supernodes" ]]; then + # Current state is the highest-height entry per supernode; states are + # intentionally out of order to exercise max_by(height). + cat <<'JSON' +{ + "supernodes": [ + {"validator_address": "lumeravaloper1alpha", "states": [ + {"state": "SUPERNODE_STATE_ACTIVE", "height": "10"}, + {"state": "SUPERNODE_STATE_POSTPONED", "height": "30"}, + {"state": "SUPERNODE_STATE_ACTIVE", "height": "20"} + ]}, + {"validator_address": "lumeravaloper1beta", "states": [ + {"state": "SUPERNODE_STATE_ACTIVE", "height": "5"} + ]}, + {"validator_address": "lumeravaloper1gamma", "states": [ + {"state": "SUPERNODE_STATE_DISABLED", "height": "7"}, + {"state": "SUPERNODE_STATE_ACTIVE", "height": "99"} + ]} + ], + "pagination": {"total": "3"} } JSON exit 0 @@ -93,15 +126,18 @@ JSON fi if [[ "${args[0]}" == "query" && "${args[1]}" == "staking" && "${args[2]}" == "delegations-to" ]]; then + # Single-element pages with a larger pagination.total: this models a real node + # answering --page-count-total --page-limit 1, and guards against the regression + # where the count came from the (truncated) array length instead of the total. case "${args[3]}" in lumeravaloper1alpha) cat <<'JSON' -{"delegation_responses": [{}, {}, {}, {}]} +{"delegation_responses": [{}], "pagination": {"total": "4"}} JSON ;; lumeravaloper1beta) cat <<'JSON' -{"delegation_responses": [{}, {}, {}, {}, {}, {}, {}]} +{"delegation_responses": [{}], "pagination": {"total": "7"}} JSON ;; esac @@ -111,13 +147,14 @@ fi if [[ "${args[0]}" == "query" && "${args[1]}" == "staking" && "${args[2]}" == "unbonding-delegations-from" ]]; then case "${args[3]}" in lumeravaloper1alpha) + # Empty set: a real node omits pagination.total here; the script must read 0. cat <<'JSON' -{"unbonding_responses": [{}, {}]} +{"unbonding_responses": [], "pagination": {}} JSON ;; lumeravaloper1beta) cat <<'JSON' -{"unbonding_responses": [{}]} +{"unbonding_responses": [{}], "pagination": {"total": "1"}} JSON ;; esac @@ -219,7 +256,7 @@ teardown() { and .mode == "evmigration-estimate" and .exact == true and .max_observed == 33 - and .suggested_cap == 37 + and .suggested_cap == 43 and .validators[0].operator_address == "lumeravaloper1beta" and .validators[0].total == 33 ' @@ -239,7 +276,7 @@ teardown() { .mode == "staking-pre-evm" and .exact == true and .max_observed == 11 - and .suggested_cap == 13 + and .suggested_cap == 15 and .validators[0].operator_address == "lumeravaloper1beta" and .validators[0].val_redelegation_count == 3 ' @@ -259,7 +296,8 @@ teardown() { [[ "$output" != *"INFO: scanning redelegations 1/2"* ]] [[ "$output" == *"INFO: scanned redelegations 1/2: alpha count=1"* ]] [[ "$output" == *"INFO: scanned redelegations 2/2: beta count=2"* ]] - [[ "$output" == *"INFO: counting staking records 2/2"* ]] + [[ "$output" == *"INFO: counting staking records 1/2: alpha count=7 (deleg=4 unbond=0 redel=3)"* ]] + [[ "$output" == *"INFO: counting staking records 2/2: beta count=11 (deleg=7 unbond=1 redel=3)"* ]] [[ "$output" == *"mode: staking-pre-evm"* ]] } @@ -312,3 +350,36 @@ teardown() { and (.warnings[0] | contains("not safe")) ' } + +@test "stats reports accounts, validators, and supernode states (json)" { + run "$SCRIPTS_DIR/chain-helper.sh" stats \ + --binary "$FAKE_LUMERAD" \ + --chain-id test-chain \ + --node tcp://test:26657 \ + --json + + [ "$status" -eq 0 ] + echo "$output" | jq -e ' + .command == "stats" + and .accounts.total == 160021 + and .validators.total == 2 + and .validators.jailed == 1 + and .validators.not_jailed == 1 + and .supernodes.total == 3 + and ((.supernodes.by_state[] | select(.state == "SUPERNODE_STATE_ACTIVE") | .count) == 2) + and ((.supernodes.by_state[] | select(.state == "SUPERNODE_STATE_POSTPONED") | .count) == 1) + ' +} + +@test "stats human output groups supernodes by current (highest-height) state" { + run "$SCRIPTS_DIR/chain-helper.sh" stats \ + --binary "$FAKE_LUMERAD" \ + --chain-id test-chain \ + --node tcp://test:26657 + + [ "$status" -eq 0 ] + [[ "$output" == *"total: 160021"* ]] + [[ "$output" == *"jailed: 1"* ]] + [[ "$output" == *"SUPERNODE_STATE_ACTIVE: 2"* ]] + [[ "$output" == *"SUPERNODE_STATE_POSTPONED: 1"* ]] +} diff --git a/x/evmigration/types/params.go b/x/evmigration/types/params.go index 31f17457..6f64698e 100644 --- a/x/evmigration/types/params.go +++ b/x/evmigration/types/params.go @@ -26,7 +26,7 @@ // is reached, additional claims in the same block are rejected. This // prevents a burst of migrations from consuming excessive block gas. // -// MaxValidatorDelegations (uint64, default: 2000) +// MaxValidatorDelegations (uint64, default: 2500) // // Safety cap for MsgMigrateValidator. A validator migration must re-key // every delegation record. If the total number of delegation + unbonding @@ -45,7 +45,10 @@ var ( // DefaultMaxMigrationsPerBlock caps claim messages per block. DefaultMaxMigrationsPerBlock uint64 = 50 // DefaultMaxValidatorDelegations caps delegation records for validator migration. - DefaultMaxValidatorDelegations uint64 = 2000 + // Sized against live mainnet, where the largest validator's migration-object + // count (delegations + unbondings + redelegations) is ~1,632; 2500 clears that + // worst case with ~1.5x headroom. See scripts/chain-helper.sh max-validator-delegations. + DefaultMaxValidatorDelegations uint64 = 2500 // DefaultMaxMultisigSubKeys caps the number of sub-keys a multisig legacy // account may have when migrating. Bounds per-tx verification cost. DefaultMaxMultisigSubKeys uint32 = 20 From c88318549396515cf4f9400c78f228e81dfc3faf Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Wed, 10 Jun 2026 14:34:18 -0400 Subject: [PATCH 2/5] ci: save Go module cache after download --- .github/actions/setup-go/action.yml | 66 ++++++++++++++++++++++++++++- .github/workflows/build.yml | 6 --- .github/workflows/test.yml | 12 ------ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index f7987984..f83957e5 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -1,10 +1,20 @@ name: Setup Go from go.mod -description: Detect the Go toolchain version from go.mod and install it with caching enabled +description: Detect the Go toolchain version from go.mod, install it, and prime the Go module cache inputs: version: description: "Go version to use (overrides go.mod detection)" required: false default: "" + download-modules: + description: "Run go mod download and save the module cache immediately" + required: false + default: "true" + module-directories: + description: "Newline-delimited module directories to download" + required: false + default: | + . + tests/systemtests outputs: version: description: Detected Go version @@ -42,8 +52,60 @@ runs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Setup Go + - id: setup-go + name: Setup Go uses: actions/setup-go@v6.2.0 with: go-version: ${{ steps.determine.outputs.version }} cache: true + cache-dependency-path: | + go.sum + tests/systemtests/go.sum + + - id: go-env + name: Resolve Go cache paths + shell: bash + run: | + set -euo pipefail + + echo "gomodcache=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + + - id: go-module-cache + name: Restore Go module cache + if: ${{ inputs.download-modules == 'true' && steps.setup-go.outputs.cache-hit != 'true' }} + uses: actions/cache/restore@v4 + with: + path: ${{ steps.go-env.outputs.gomodcache }} + key: go-mod-${{ runner.os }}-${{ runner.arch }}-go-${{ steps.determine.outputs.version }}-${{ hashFiles('go.sum', 'tests/systemtests/go.sum') }} + restore-keys: | + go-mod-${{ runner.os }}-${{ runner.arch }}-go-${{ steps.determine.outputs.version }}- + go-mod-${{ runner.os }}-${{ runner.arch }}- + + - name: Download Go modules + if: ${{ inputs.download-modules == 'true' }} + shell: bash + env: + MODULE_DIRECTORIES: ${{ inputs.module-directories }} + run: | + set -euo pipefail + + while IFS= read -r module_dir || [ -n "$module_dir" ]; do + if [ -z "$module_dir" ]; then + continue + fi + if [ ! -f "$module_dir/go.mod" ]; then + echo "Module directory '$module_dir' does not contain go.mod" >&2 + exit 1 + fi + + echo "Downloading Go modules in $module_dir" + (cd "$module_dir" && go mod download) + done <<< "$MODULE_DIRECTORIES" + + - name: Save Go module cache + if: ${{ inputs.download-modules == 'true' && steps.setup-go.outputs.cache-hit != 'true' && steps.go-module-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + continue-on-error: true + with: + path: ${{ steps.go-env.outputs.gomodcache }} + key: ${{ steps.go-module-cache.outputs.cache-primary-key }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 116ea038..dea77f8a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,9 +33,6 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Run unit tests run: make unit-tests @@ -49,9 +46,6 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Run integration tests run: make integration-tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ea85a86..443dab0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,9 +25,6 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Run unit tests run: make unit-tests @@ -42,9 +39,6 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Build lumerad run: go build -o ./build/lumerad ./cmd/lumera @@ -62,9 +56,6 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Run integration tests run: make integration-tests @@ -80,8 +71,5 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go - - name: Install dependencies - run: go mod download - - name: Run simulation benchmark run: make simulation-bench From aff75444a8eccbede9b349a0f75fdc33623f65cb Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Wed, 10 Jun 2026 15:01:38 -0400 Subject: [PATCH 3/5] feat(evmigration): auto-set testnet migration_end_time to upgrade + 7 days Generalize the v1.20.0 upgrade handler's devnet-only deadline into a per-network autoMigrationWindow: devnet = 2 days, testnet = 7 days, mainnet = 0 (specific absolute timestamp chosen near launch). The handler applies the window only when > 0, so mainnet and any unrecognized chain ID fall through to the manual-timestamp path. Updates upgrade tests (testnet now asserts +7 days; mainnet asserts 0) and the rollout doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/upgrades/v1_20_0/upgrade.go | 53 +++++++++++++------- app/upgrades/v1_20_0/upgrade_test.go | 26 ++++++++-- docs/evm-integration/architecture/rollout.md | 6 +-- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app/upgrades/v1_20_0/upgrade.go b/app/upgrades/v1_20_0/upgrade.go index 3d92e3b8..1ce66813 100644 --- a/app/upgrades/v1_20_0/upgrade.go +++ b/app/upgrades/v1_20_0/upgrade.go @@ -26,12 +26,28 @@ import ( // UpgradeName is the on-chain name used for this upgrade. const UpgradeName = "v1.20.0" -// devnetMigrationWindow is how long after the upgrade block the migration -// window stays open on devnet. Devnet derives a finite migration_end_time -// automatically so rehearsals exercise a real deadline; testnet and mainnet -// instead receive a specific absolute migration_end_time chosen near their -// own upgrade. -const devnetMigrationWindow = 2 * 24 * time.Hour +// Devnet and testnet derive a finite migration_end_time automatically from the +// upgrade block time so rehearsals and public testnet exercise a real deadline. +// Mainnet instead receives a specific absolute migration_end_time chosen near +// its own upgrade (the handler leaves it at the default 0). +const ( + devnetMigrationWindow = 2 * 24 * time.Hour + testnetMigrationWindow = 7 * 24 * time.Hour +) + +// autoMigrationWindow returns the migration window to auto-apply for the given +// chain ID, or 0 if the deadline should be left to a manually chosen timestamp +// (mainnet and any unrecognized chain ID). +func autoMigrationWindow(chainID string) time.Duration { + switch { + case lcfg.IsDevnetChainID(chainID): + return devnetMigrationWindow + case lcfg.IsTestnetChainID(chainID): + return testnetMigrationWindow + default: + return 0 + } +} // StoreUpgrades declares store additions for this upgrade. var StoreUpgrades = storetypes.StoreUpgrades{ @@ -137,28 +153,29 @@ func CreateUpgradeHandler(p appParams.AppUpgradeParams) upgradetypes.UpgradeHand } } - // On devnet, derive a finite migration_end_time from the upgrade block - // time so rehearsals run against a real deadline without hardcoding an - // absolute timestamp. RunMigrations already seeded the evmigration module - // with default params (enable_migration=true, migration_end_time=0); here - // we only override the deadline. Testnet and mainnet keep the default 0 - // at upgrade and receive a specific migration_end_time chosen separately. - if lcfg.IsDevnetChainID(p.ChainID) { + // On devnet and testnet, derive a finite migration_end_time from the + // upgrade block time so those networks run against a real deadline without + // hardcoding an absolute timestamp. RunMigrations already seeded the + // evmigration module with default params (enable_migration=true, + // migration_end_time=0); here we only override the deadline. Mainnet keeps + // the default 0 at upgrade and receives a specific migration_end_time + // chosen separately near launch. + if window := autoMigrationWindow(p.ChainID); window > 0 { if p.EvmigrationKeeper == nil { - return nil, fmt.Errorf("%s devnet upgrade requires evmigration keeper to be wired", UpgradeName) + return nil, fmt.Errorf("%s upgrade requires evmigration keeper to be wired", UpgradeName) } emParams, err := p.EvmigrationKeeper.Params.Get(ctx) if err != nil { return nil, fmt.Errorf("get evmigration params: %w", err) } - emParams.MigrationEndTime = ctx.BlockTime().Add(devnetMigrationWindow).Unix() + emParams.MigrationEndTime = ctx.BlockTime().Add(window).Unix() if err := p.EvmigrationKeeper.Params.Set(ctx, emParams); err != nil { - return nil, fmt.Errorf("set devnet evmigration migration_end_time: %w", err) + return nil, fmt.Errorf("set evmigration migration_end_time: %w", err) } - p.Logger.Info("Set devnet migration_end_time", + p.Logger.Info("Set migration_end_time from upgrade block time", "chain_id", p.ChainID, "migration_end_time", emParams.MigrationEndTime, - "window", devnetMigrationWindow.String(), + "window", window.String(), ) } diff --git a/app/upgrades/v1_20_0/upgrade_test.go b/app/upgrades/v1_20_0/upgrade_test.go index 46c9cf0f..6d5881eb 100644 --- a/app/upgrades/v1_20_0/upgrade_test.go +++ b/app/upgrades/v1_20_0/upgrade_test.go @@ -62,20 +62,38 @@ func TestV1200SetsDevnetMigrationEndTime(t *testing.T) { "max_validator_delegations default should be 2500") } -// Testnet and mainnet keep migration_end_time at the default 0 at upgrade; a -// specific absolute timestamp is chosen for them separately. -func TestV1200LeavesMigrationEndTimeZeroOffDevnet(t *testing.T) { +// On testnet the handler derives a 7-day migration window from the upgrade +// block time. +func TestV1200SetsTestnetMigrationEndTime(t *testing.T) { app := lumeraapp.Setup(t) ctx := app.BaseApp.NewContext(false) + want := ctx.BlockTime().Add(7 * 24 * time.Hour).Unix() + handler := upgradev1200.CreateUpgradeHandler(upgradeParamsForChain(app, "lumera-testnet-1")) _, err := handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) require.NoError(t, err) + after, err := app.EvmigrationKeeper.Params.Get(ctx) + require.NoError(t, err) + require.Equal(t, want, after.MigrationEndTime, + "testnet upgrade should set migration_end_time to upgrade block time + 7 days") +} + +// Mainnet keeps migration_end_time at the default 0 at upgrade; a specific +// absolute timestamp is chosen and applied separately near launch. +func TestV1200LeavesMigrationEndTimeZeroOnMainnet(t *testing.T) { + app := lumeraapp.Setup(t) + ctx := app.BaseApp.NewContext(false) + + handler := upgradev1200.CreateUpgradeHandler(upgradeParamsForChain(app, "lumera-mainnet-1")) + _, err := handler(sdk.WrapSDKContext(ctx), upgradetypes.Plan{}, module.VersionMap{}) + require.NoError(t, err) + after, err := app.EvmigrationKeeper.Params.Get(ctx) require.NoError(t, err) require.Equal(t, int64(0), after.MigrationEndTime, - "non-devnet upgrade must leave migration_end_time at the default 0") + "mainnet upgrade must leave migration_end_time at the default 0") } func TestV1200InitializesERC20ParamsWhenInitGenesisIsSkipped(t *testing.T) { diff --git a/docs/evm-integration/architecture/rollout.md b/docs/evm-integration/architecture/rollout.md index 9a46fe9c..bfba99f2 100644 --- a/docs/evm-integration/architecture/rollout.md +++ b/docs/evm-integration/architecture/rollout.md @@ -114,7 +114,7 @@ The short list that genuinely needs a value chosen before tagging is the `x/evmi | Parameter | Code default | Live mainnet signal | Decision | | --- | --- | --- | --- | | `enable_migration` | `true` (immediate-open) | — | **Keep `true` — immediate-open.** Migration opens as soon as the upgraded chain produces blocks; the upgrade handler leaves `enable_migration=true` with no controlled-open gating. Public messaging, support, and RPC capacity must therefore be live before validators restart. | -| `migration_end_time` | `0` (no deadline) | 160,021 accounts and 83 validators to migrate | **Devnet: auto-set by the `v1.20.0` upgrade handler** to upgrade-block time + **2 days** (no hardcoded timestamp). **Testnet and mainnet:** the handler leaves it `0`; set a specific absolute Unix timestamp (seconds) — a chosen wall-clock end time, ~**+120 days** past the planned upgrade — closer to launch. Must be non-zero before mainnet while the proof format has no expiry. | +| `migration_end_time` | `0` (no deadline) | 160,021 accounts and 83 validators to migrate | **Auto-set by the `v1.20.0` upgrade handler from the upgrade-block time:** devnet = + **2 days**, testnet = + **7 days** (no hardcoded timestamp). **Mainnet:** the handler leaves it `0`; set a specific absolute Unix timestamp (seconds) — a chosen wall-clock end time, ~**+120 days** past the planned upgrade — closer to launch. Must be non-zero before mainnet while the proof format has no expiry. | | `max_validator_delegations` | `2500` (raised from 2000) | worst-case validator migration-object count is **1,632** (`chain-helper.sh` reports `max_observed: 1632`, `suggested_cap: 2122` at the default 30% buffer) | **Shipped default is now `2500`** — clears the worst case (1,632) with ~1.53× headroom; `3000` judged over-provisioned given the short runway. Re-check with `chain-helper.sh` near tag time. | | `max_migrations_per_block` | `50` | at 50/block (~5s blocks) the full 160k account base clears in ~11 days of continuous saturation | **Keep `50`.** Adequate for a weeks-long window; only revisit if load tests show RPC or block-production stress. | | `max_multisig_sub_keys` | `20` | not enumerable from public state — multisig pubkeys are only revealed on-chain after the group's first tx | **Keep `20`** unless the internal key inventory knows of a larger foundation/treasury multisig; confirm against that inventory rather than chain state. | @@ -156,7 +156,7 @@ The short list that genuinely needs a value chosen before tagging is the `x/evmi | Parameter / decision | Current code default | Required decision before tag | Why it matters | Verification | | --- | --- | --- | --- | --- | | `enable_migration` | `true` | **Decided: immediate-open.** Keep `enable_migration=true`; the upgrade handler leaves it enabled so migration opens at the first post-upgrade block | if `true`, migration can open as soon as the upgraded chain produces blocks | `lumerad q evmigration params` immediately after upgrade | -| `migration_end_time` | `0` meaning no deadline | **Decided:** devnet auto-set by the upgrade handler to upgrade-block time + 2 days; testnet/mainnet left `0` by the handler and given a specific absolute Unix timestamp (~+120 days) chosen near launch | an unlimited window extends support and proof-replay risk indefinitely | query params and compare the Unix timestamp to the public migration-window announcement | +| `migration_end_time` | `0` meaning no deadline | **Decided:** auto-set by the upgrade handler from upgrade-block time — devnet + 2 days, testnet + 7 days; mainnet left `0` by the handler and given a specific absolute Unix timestamp (~+120 days) chosen near launch | an unlimited window extends support and proof-replay risk indefinitely | query params and compare the Unix timestamp to the public migration-window announcement | | `max_migrations_per_block` | `50` | **Decided: keep `50`** — adequate per-block claim throttle; revisit only if RC/devnet load tests show stress | too high can stress blocks/RPC; too low can create user backlog | run migration traffic at the proposed limit alongside normal Cosmos/EVM traffic | | `max_validator_delegations` | `2500` | **Decided & shipped: `2500`** (raised from 2000) — clears the live worst case of 1,632 migration objects (`chain-helper.sh` `max_observed: 1632`) with ~1.53× headroom; `3000` judged over-provisioned given the short runway | validator migrations iterate delegations, unbondings, and redelegations; under-sizing blocks large validators, over-sizing increases per-tx work | run `scripts/chain-helper.sh max-validator-delegations --json` (works today in `staking-pre-evm` mode) and confirm the chosen cap exceeds the observed maximum plus buffer | | `max_multisig_sub_keys` | `20` | **Decided: keep `20`** — confirm against the internal key inventory rather than chain state | larger values increase proof size, gas, and coordinator complexity | rehearse multisig migrations at or near the proposed ceiling and verify ante / keeper rejection above it | @@ -172,7 +172,7 @@ Before testnet and mainnet, choose one of these activation policies and test tha - **Immediate-open policy**: migration is available immediately after the upgrade. This is operationally simpler, but public messaging, support staffing, RPC capacity, and migration monitoring must be live before validators restart. - **Controlled-open policy**: migration remains disabled until post-upgrade smoke tests pass. This requires an implementation or governance path that is already effective at upgrade time; a normal post-upgrade parameter proposal is too slow to prevent an initial open window. -**Decision:** Lumera uses the **immediate-open policy** — `enable_migration=true` at the first post-upgrade block — paired with a finite `migration_end_time` (a specific absolute Unix timestamp in seconds, compared against block time). On **devnet** the `v1.20.0` upgrade handler sets this automatically to upgrade-block time + **2 days**. On **testnet and mainnet** the handler leaves it `0`; a specific absolute timestamp (~**120 days** past the upgrade) is chosen and applied near launch. Because migration opens immediately, public messaging, support staffing, RPC capacity, and migration monitoring must be live before validators restart. +**Decision:** Lumera uses the **immediate-open policy** — `enable_migration=true` at the first post-upgrade block — paired with a finite `migration_end_time` (a specific absolute Unix timestamp in seconds, compared against block time). On **devnet and testnet** the `v1.20.0` upgrade handler sets this automatically from the upgrade-block time (devnet + **2 days**, testnet + **7 days**). On **mainnet** the handler leaves it `0`; a specific absolute timestamp (~**120 days** past the upgrade) is chosen and applied near launch. Because migration opens immediately, public messaging, support staffing, RPC capacity, and migration monitoring must be live before validators restart. Mainnet should not rely on an assumed manual "open the window" step; the release candidate must prove that `enable_migration=true` and the intended `migration_end_time` are already in state at the first post-upgrade block. From 8ce044d1e24b40da2ff1aadd9de2c1ca027c5277 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 11 Jun 2026 12:28:32 -0400 Subject: [PATCH 4/5] Fix devnet height parsing --- devnet/tests/common/chaincli.go | 11 ++++++++++- devnet/tests/common/chaincli_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/devnet/tests/common/chaincli.go b/devnet/tests/common/chaincli.go index 34d1b937..d3efcf4f 100644 --- a/devnet/tests/common/chaincli.go +++ b/devnet/tests/common/chaincli.go @@ -83,6 +83,9 @@ func (c *ChainCLI) LatestHeight() (int64, error) { } } var resp struct { + Header *struct { + Height string `json:"height"` + } `json:"header"` Block *struct { Header struct { Height string `json:"height"` @@ -97,11 +100,17 @@ func (c *ChainCLI) LatestHeight() (int64, error) { LatestBlockHeight string `json:"latest_block_height"` } `json:"sync_info"` } - if err := json.Unmarshal([]byte(out), &resp); err != nil { + payload := out + if extracted, ok := ExtractJSONPayload(out); ok { + payload = extracted + } + if err := json.Unmarshal([]byte(payload), &resp); err != nil { return 0, fmt.Errorf("parse height: %s: %w", truncate(out, 200), err) } var heightStr string switch { + case resp.Header != nil: + heightStr = resp.Header.Height case resp.Block != nil: heightStr = resp.Block.Header.Height case resp.SdkBlock != nil: diff --git a/devnet/tests/common/chaincli_test.go b/devnet/tests/common/chaincli_test.go index 79c8442f..f8d99dc1 100644 --- a/devnet/tests/common/chaincli_test.go +++ b/devnet/tests/common/chaincli_test.go @@ -90,6 +90,34 @@ func TestSubmitTxRejectsUnparseableBroadcastOutput(t *testing.T) { } } +func TestLatestHeightParsesMixedStatusOutput(t *testing.T) { + out := `Falling back to latest block height: +{"sync_info":{"latest_block_height":"335475"}}` + cli := &ChainCLI{Bin: staticLumerad(t, out, 0), ChainID: "chain", RPC: "tcp://localhost:26657"} + + height, err := cli.LatestHeight() + if err != nil { + t.Fatalf("LatestHeight error: %v", err) + } + if height != 335475 { + t.Fatalf("LatestHeight = %d, want 335475", height) + } +} + +func TestLatestHeightParsesTopLevelBlockHeader(t *testing.T) { + out := `Falling back to latest block height: +{"header":{"version":{"block":"11","app":"0"},"chain_id":"lumera-devnet-1","height":"344943"}}` + cli := &ChainCLI{Bin: staticLumerad(t, out, 0), ChainID: "chain", RPC: "tcp://localhost:26657"} + + height, err := cli.LatestHeight() + if err != nil { + t.Fatalf("LatestHeight error: %v", err) + } + if height != 344943 { + t.Fatalf("LatestHeight = %d, want 344943", height) + } +} + func TestWaitForNextBlockReturnsInitialHeightError(t *testing.T) { cli := &ChainCLI{Bin: staticLumerad(t, "rpc unavailable", 1), ChainID: "chain", RPC: "tcp://localhost:26657"} From 5de771c73db25fa8b3ab243ecd508d994d657069 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 11 Jun 2026 15:41:02 -0400 Subject: [PATCH 5/5] Fix supernode stats grouping --- scripts/chain-helper.sh | 1 + tests/scripts/chain-helper.bats | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/chain-helper.sh b/scripts/chain-helper.sh index fb72169d..da91b02e 100755 --- a/scripts/chain-helper.sh +++ b/scripts/chain-helper.sh @@ -651,6 +651,7 @@ stats() { | (.states // []) | if length > 0 then (max_by(.height | tonumber).state) else "SUPERNODE_STATE_UNSPECIFIED" end ] + | sort_by(.) | group_by(.) | map({state: .[0], count: length}) | sort_by(-.count) diff --git a/tests/scripts/chain-helper.bats b/tests/scripts/chain-helper.bats index 2782cd8b..262bc83b 100644 --- a/tests/scripts/chain-helper.bats +++ b/tests/scripts/chain-helper.bats @@ -54,18 +54,18 @@ JSON fi if [[ "${args[0]}" == "query" && "${args[1]}" == "supernode" && "${args[2]}" == "list-supernodes" ]]; then - # Current state is the highest-height entry per supernode; states are - # intentionally out of order to exercise max_by(height). + # Current state is the highest-height entry per supernode; history entries + # and resulting current states are intentionally out of order. cat <<'JSON' { "supernodes": [ {"validator_address": "lumeravaloper1alpha", "states": [ {"state": "SUPERNODE_STATE_ACTIVE", "height": "10"}, - {"state": "SUPERNODE_STATE_POSTPONED", "height": "30"}, - {"state": "SUPERNODE_STATE_ACTIVE", "height": "20"} + {"state": "SUPERNODE_STATE_POSTPONED", "height": "20"}, + {"state": "SUPERNODE_STATE_ACTIVE", "height": "30"} ]}, {"validator_address": "lumeravaloper1beta", "states": [ - {"state": "SUPERNODE_STATE_ACTIVE", "height": "5"} + {"state": "SUPERNODE_STATE_POSTPONED", "height": "5"} ]}, {"validator_address": "lumeravaloper1gamma", "states": [ {"state": "SUPERNODE_STATE_DISABLED", "height": "7"}, @@ -366,8 +366,10 @@ teardown() { and .validators.jailed == 1 and .validators.not_jailed == 1 and .supernodes.total == 3 - and ((.supernodes.by_state[] | select(.state == "SUPERNODE_STATE_ACTIVE") | .count) == 2) - and ((.supernodes.by_state[] | select(.state == "SUPERNODE_STATE_POSTPONED") | .count) == 1) + and .supernodes.by_state == [ + {"state": "SUPERNODE_STATE_ACTIVE", "count": 2}, + {"state": "SUPERNODE_STATE_POSTPONED", "count": 1} + ] ' }