Skip to content

fix(evmigration): cross-module runtime continuity for account/validator migration (PR2) - #199

Draft
mateeullahmalik wants to merge 18 commits into
matee/evmigration-history-repairfrom
matee/evmigration-continuity-core
Draft

fix(evmigration): cross-module runtime continuity for account/validator migration (PR2)#199
mateeullahmalik wants to merge 18 commits into
matee/evmigration-history-repairfrom
matee/evmigration-continuity-core

Conversation

@mateeullahmalik

Copy link
Copy Markdown
Contributor

PR2 — EVM migration runtime continuity core

Stacked on #196 (a79d3628, untouched). Merge #196 first.

Chain head: d7504279 · SuperNode companion: LumeraProtocol/supernode matee/evmigration-continuity

What this changes

PR #196 fixed the destructive SuperNode-history rewrite and hardened source-account ownership. It is not a complete cross-module identity migration. This PR implements forward runtime continuity for account/validator migrations, and keeps migration disabled by default until the full gate passes.

Per the GOLDEN analysis, migration currently loses or splits identity-keyed state across seven independent surfaces. Each commit closes one.

Commit Scope
32f38d3f SuperNode/Everlight validator continuity — moves snm_/rdist/, keeps rhist/ immutable, collision + orphan detection
55822e71 Audit/LEP-6 identity continuity — forward/reverse transition index, audit consensus v2→v3, logical-vs-current split across reports/dedup/reliability/suspicion/heal/recheck/postponement
0d3685bc evmigration applies both plans pre-write + canary gate (disabled/canary/open); enable_migration now defaults false
9015af7e Audit query exposes logical reporter + ordered logical/current target mappings (the contract the SN daemon consumes)
2c5818c5 Action lifecycle matrix — rewrite Creator through DONE, preserve SuperNodes after finalization, preserve everything in terminal states
1864d947 P0 — staking maturity queues: SDK RemoveUnbondingDelegation/RemoveRedelegation do not clean timeslices, so old UBD/RED queue tuples were never removed and could wedge staking EndBlock
267fcba2 Retained SDK state — StakeAuthorization allow/deny valoper rewrite, gov votes/deposits, third-party withdraw addrs, bounded caps, field-7 zero-value compat
a1ead828 Canary gate enforced at the ante + BaseApp atomicity/state-neutrality proofs
1b8f1b84 Migration cohort matrix + Everlight next-tick continuity
d7504279 Repair remaining default-false test fallout + lint

State keys

Moved exactly once (source deleted, destination asserted absent first): snm_<val> latest metrics · rdist/<val> Everlight accumulator · audit live singletons (suspicion, reliability, action/storage markers) · staking delegation/UBD/RED primaries and their maturity-queue timeslices · authz grants · gov votes/deposits · distribution withdraw addresses · action Creator (pre-finalization).

Immutable, never rewritten: rhist/ payout history · audit anchors/reports/evidence/facts/transcripts · action SuperNodes after finalization · everything in terminal action states.

Added: AccountTransition lineage (forward + reverse index) · EpochReport.current_submitter (field 7) · Params.canary_legacy_addresses (6), Params.max_retained_state_entries (7).

ABCI phases

  • CheckTx/ReCheckTx — no mutation. Ante enforces activation (disabled/canary/window) + proofs + cheap state admission. Proven state-neutral.
  • Simulate — plan builder may run; nothing commits; gas still accounted.
  • FinalizeBlock — complete plan rebuilt against current state, every fragment validated, then applied in fixed order under the BaseApp tx cache.
  • BeginBlock/EndBlock — frozen epochs resolve as-of identity; live singletons target the current account.
  • Replay — sorted plan fragments, no wall clock, no map iteration order, no RPC.

Upgrade requirement

A coordinated consensus activation is required. Old and new binaries execute the same migration transaction differently, so a mixed-binary rollout while migration is executable is consensus-unsafe. enable_migration now defaults to false, so the safe posture is: ship with migration disabled, then activate via governance disabled → canary → open only after the full gate passes.

New persisted state (audit transition lineage, report field 7, two new params) means audit and evmigration module-version migrations are included. Field 7 zero-values decode correctly for params serialized before it existed (EffectiveMaxRetainedStateEntries()).

Risks

  • Widest-blast-radius change is the staking maturity-queue rewrite (1864d947). It touches shared timeslices consumed by staking EndBlock; a wrong write wedges the chain. Mitigated by exact-tuple preconditions (oldCount == 1 && newCount == 0), re-verification of every snapshot immediately before the first write, and destination-collision + delegation starting-info preflight.
  • Retained-state discovery iterates authz/gov; bounded by max_retained_state_entries (default 10000), rejecting at cap+1 before any write.
  • Migration remains disabled by default; do not enable canary until the devnet rehearsal and multi-validator/multi-SN functional gate pass.

Rollback

Migration is disabled by default, so the deployed-but-inactive state is the rollback state. Emergency governance MsgUpdateParams setting enable_migration=false halts all further migrations; it cannot reverse already-committed ones. Reverting the branch pre-activation is safe because no migration can have executed.

Non-goals

Repairing already-migrated testnet state · rewriting historical reports/evidence/payouts/anchors/transcripts · generic Wasm/EVM contract-state ownership transfer · ICA/group/circuit authority transfer · broad query/UI aggregation.

Observability

Migration events carry source/destination identity, per-family migrated counts, collision-rejection family, audit lineage activation, Everlight state move, and migration record ID. No secrets or proof material. Blocker codes are a stable enum shared by preflight and operator output.

Evidence

Local CI parity on the exact head d7504279, immutable tree:

make lint ................................................ 0 issues
make unit-tests NOCACHE=1 ................................ PASS
make integration-tests NOCACHE=1 ......................... PASS
go test -tags=test ./app/... ............................. PASS
git diff --check ......................................... clean

Cross-repo, via a throwaway go.work (never committed): the SuperNode companion builds against this chain head, and pkg/lumera/modules/audit, supernode/host_reporter, supernode/storage_challenge all pass.

Mutation-tested, not just green — two assertions were verified to fail when the behavior they guard is broken:

  • replacing AccountForEpoch with the raw Creator makes the cohort suite fail on migrated actors;
  • forcing a surviving migration record makes the BaseApp rollback test fail.

Notable coverage added here: none/one/some/all migrated cohort matrix; current-epoch and next-epoch report continuity; Everlight next-tick payout equality against a never-migrated control, with a negative control proving a lost accumulator does change the payout; BaseApp late-failure rollback (failing in step 7 of 7, after every other module has written); CheckTx/ReCheckTx/Simulate state neutrality.

One qualified ante side effect is asserted explicitly rather than hidden: wasmd's CountTXDecorator commits a per-block tx counter at wasm key 0x08 for any block-admitted tx. It carries an 8-byte height and 4-byte count, no identity.

Not yet done — do not enable canary before these

  • Devnet FROM_TAGTO_TAG upgrade rehearsal on mainnet-shaped state
  • Multi-validator / multi-SuperNode functional gate (upload, download, action verification, full audit epoch)
  • make system-tests / make systemex-tests
  • SuperNode companion PR merged and a release artifact cut; SuperNodes must be compatible before canary governance executes

Behavior change
---------------
1. VerifyMigrationProofsForAnte now enforces the canary allowlist, not just
   EnableMigration and the migration window. Migration txs are fee-free and
   zero-signer, so before this change canary mode still admitted unlimited
   txs from arbitrary (non-allowlisted) sources into the mempool and into
   block proposals, each paying full multisig proof verification in CheckTx,
   only to be rejected by preChecks in DeliverTx.

   The activation decision is now a single shared helper,
   CheckMigrationActivation, called by both the ante admission gate and
   message execution, so the mempool filter can no longer diverge from the
   consensus decision (spec s4 "one activation policy helper shared by
   account and validator messages"). preChecks remains authoritative; the
   ante is a best-effort mempool filter, unchanged in that role.

2. Repairs five tests left red by 0d3685b (enable_migration default false).
   They live in app/ and app/evm, outside the previously verified package
   set, and asserted enabled-by-default behavior. They now open the
   activation gate explicitly so they still exercise the proof /
   admission / proposal behavior they name instead of short-circuiting on
   "migration is disabled". Disabled-by-default remains pinned in
   x/evmigration/keeper/ante_test.go.

Rationale
---------
Invariant I19 (activation) requires the disabled/canary/open policy to hold
at every message entry point. The ante is a message entry point for
zero-fee, zero-signature txs and was the one place the canary list was not
consulted.

Tests
-----
RED-then-GREEN per invariant:

- TestVerifyMigrationProofsForAnte_CanaryGate (I19): unlisted source
  rejected, allowlisted source admitted, empty list leaves open mode
  unchanged, validator migration honours the same list. Confirmed failing
  before the production change.
- TestEVMigration_BaseAppLateFailureRollsBackEveryStore (I20): drives the
  production BaseApp FinalizeBlock path and fails in the LAST migration step
  (step 7, MigrateActions) via a dangling creator index, so rollback is
  exercised only after distribution, staking, auth, bank, retained SDK
  state, feegrant, audit and supernode writes have already occurred in the
  tx cache. Asserted differentially against an empty control block on the
  same app; no migration-owned or identity-bearing store may appear in the
  delta, and no committed key or value may reference either identity.
- TestEVMigration_CheckTxReCheckTxSimulateAreStateNeutral (spec s5): all
  three phases individually and in sequence leave committed state byte-
  identical.

Qualified ante side effect: the failing-tx block legitimately commits
wasmd CountTXDecorator's per-block tx counter at wasm key 0x08. It carries
an 8-byte height and 4-byte count only, no identity, and is asserted
explicitly rather than allow-listed away, per the spec requirement to
assert ante effects separately instead of claiming the whole app store is
unchanged.

The rollback assertion was mutation-tested (forcing a surviving migration
record makes it fail), confirming it is load-bearing.

Risks
-----
Narrows mempool admission only. No new state keys, no proto change, no
migration, no module-version change. A previously-admitted-then-rejected tx
is now rejected earlier and never reaches a block, which is the intent.

Rollback
--------
Revert this commit; the canary list remains enforced in consensus by
preChecks, so reverting weakens mempool filtering only, not the state
machine.

Observability
-------------
No new events. Rejections surface as the existing ErrMigrationNotCanary.

Verification
------------
go test -tags=test ./app/... .............. PASS
go test ./x/... ........................... PASS
go test -tags='integration test' ./tests/integration/evmigration/... PASS
make lint ................................. 0 issues
git diff --check .......................... clean
…ntinuity

Adds the continuity fixtures called for by spec s6.5-6.6 and GOLDEN s7/s10
that had no coverage. Test-only; no production behavior changes.

Everlight (I4, GOLDEN s7)
-------------------------
TestEverlightNextTickContinuityAfterValidatorMigration asserts that the NEXT
distribution after a validator-address migration pays exactly what it would
have paid with no migration, as a differential against a control chain
seeded identically.

Proving the rdist row was copied is not sufficient: distributePool reads
SNDistState keyed by validator address and feeds PrevRawBytes into the
growth cap, SmoothedBytes into the EMA and PeriodsActive into the ramp-up
weight. A plan that moved the bytes but left the consumer reading the old
key, or that silently reset the accumulator, still produces a wrong payout.
The test drives the real tick, then asserts the accumulator advances under
the destination validator and is not re-created under the source.

Two properties make the assertion non-vacuous, both found by initial RED
runs rather than assumed:

- params enable every state-dependent lever (ramp-up 4, smoothing 4, 10%
  growth cap). With ramp-up off and smoothing 1 a lost accumulator pays the
  same amount and the test proves nothing.
- a second, never-migrated supernode shares the pool. A single SN receives
  100% regardless of weight, which also makes payout equality trivial.

TestEverlightDiscontinuityWouldChangeNextPayout is the negative control: a
LOST accumulator (the pre-fix behavior) must change the payout. If it does
not, the continuity assertion above is meaningless.

Audit cohort matrix (I7, I8)
----------------------------
TestEpochReportContinuityAcrossMigrationCohorts runs none/one/some/all
migrated over a 4-node cohort. The asymmetry is the dangerous case: an
epoch's active set is frozen under the accounts that existed at anchor time,
while submission authenticates against who is registered now. A mixed cohort
splits if those identities are conflated, and that is invisible in a
homogeneous all-or-nothing test. Each actor must report exactly once, the
row must land under the epoch-logical account, and current_submitter must
record the live signer.

TestEpochReportCohortRejectsDoubleReportAcrossIdentities proves a migrated
node cannot occupy two slots in one epoch by reporting under both its old
and new accounts - otherwise "some migrated" is a participation-inflation
vector, not just a continuity risk.

TestNextEpochReportsUseCurrentIdentityAfterTransition proves the other
boundary: from the effective epoch onward the current account IS the logical
identity, no new rows accrue under the retired account, and historical
epochs are never back-filled onto the new identity.

TestCohortMatrixIsExhaustive fails loudly if the matrix silently shrinks.

Evidence
--------
Mutation-tested, not just green: replacing AccountForEpoch with the raw
Creator in msg_submit_epoch_report.go makes the cohort suite fail on the
migrated actors, confirming these tests detect the exact identity-split
defect they are written for. Source restored and verified clean afterward.

Risks
-----
None to consensus: no production file is touched by this commit.

Rollback
--------
Revert; coverage returns to its prior state.
Test-only. Completes the repair of tests invalidated by 0d3685b
(enable_migration default false) and clears one staticcheck finding
introduced by the cohort matrix.

Fixed
-----
1. tests/integration/evm/mempool/evmigration_zero_signer_test.go - three
   end-to-end tests spin up real nodes and exercise the zero-signer
   mempool/ante path, which sits behind the activation gate. They were
   short-circuiting on "migration is disabled":

     TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled
     TestEVMigrationZeroSignerTxBroadcastSyncAfterLegacyMainnetConfigMigration
     TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte

   Confirmed pre-existing: all three fail identically on the unmodified
   handoff head 267fcba. Because these boot from genesis rather than an
   in-process app, the gate is opened via a new enableMigrationInGenesis
   helper that edits evmigration genesis params before StartAndWaitRPC,
   with Params.Validate() asserted so a malformed edit fails loudly.

2. staticcheck QF1002 in identity_continuity_cohort_test.go - converted an
   expression switch to a tagged switch on migrateCount.

This is the third and final location of the same class. The first two
(app/, app/evm) were repaired in a1ead82. All three were invisible to the
branch's original verified package set, which covered only ./x/... and
app/upgrades/v1_20_0.

Disabled-by-default remains deliberately pinned in
x/evmigration/keeper/ante_test.go and x/evmigration/types/params_test.go, so
opening the gate inside these consumers does not weaken that guarantee.

Risks
-----
None to consensus: no production file is touched by this commit.

Rollback
--------
Revert; the three e2e tests return to failing and lint returns one finding.
Fixes the two CI `system` job failures on this stack:

  TestStorageTruth_ScoreDecay_TriggersRecovery
  TestStorageTruth_MultipleRecheckEvidence_AccumulatesScore

Both failed with:

  out of gas in location: ReadFlat; gasWanted: 500000, gasUsed: 500331

Cause
-----
The audit account-transition lineage added by 55822e7 makes every
epoch-report submission resolve logical-vs-current identity, which reads the
forward/reverse transition index. That adds a small but unavoidable amount
of gas to a path these tests drive with a hardcoded `--gas 500000`. Observed
usage is 500331 - 331 over the cap. The tests were not asserting a gas bound;
the literal is test scaffolding, and it had already been raised once before
(200000 -> 500000, see the CP3.5 F-B comment) for the same reason when
recheck secondary indexes landed.

Not a production regression: no gas limit, block limit or fee parameter
changes, and no consensus path is altered. This is a test-scaffolding cap.

All five occurrences are raised together so the suite cannot fail piecemeal
as different tests approach the boundary, and each now carries the observed
figure so the next person does not have to re-derive it.

Evidence
--------
GitHub Actions `system` job, which runs `make install` on a clean runner and
therefore builds the binary from this exact commit:

  d750427 (before) ... FAIL  gasWanted: 500000, gasUsed: 500331
  <this commit>      ... PASS

That before/after on CI-built binaries is the load-bearing evidence.

Local systemtests are deliberately NOT cited here. On this host
/root/go/bin/lumerad is a symlink to a devnet build from a different
checkout, so `make install` fails with "Text file busy" and the suite
silently exercises a stale binary that predates 55822e7 - which cannot
reproduce the gas regression at all. Any local systemex run on this host is
therefore not evidence for or against this change. Verify with:

  ls -l $(which lumerad)   # must not be a symlink into another checkout

Rollback
--------
Revert; the two tests return to failing on the old cap.
@mateeullahmalik
mateeullahmalik force-pushed the matee/evmigration-continuity-core branch from 3664422 to c02ad4f Compare July 30, 2026 15:39
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

CI: integration flake on c02ad4f4 — investigated and cleared

The first integration run on c02ad4f4 failed. It is a flake, not a regression. Re-run is green; all 5 checks now pass.

Recording the evidence so nobody has to re-derive it.

Failure

--- FAIL: TestIndexerDisabledLookupUnavailable (6.66s)
    indexer_disabled_test.go:29: send legacy tx: eth_sendRawTransaction nonce=1
      gas_price=2490234375 failed: rpc error -32000: failed to broadcast transaction:
      exceeds block gas limit: internal

It was the only failure in the job.

Why it's a flake, on four independent grounds

  1. Identical tree already passed. c02ad4f4 is a message-only amend of 3664422egit rev-parse HEAD^{tree} is identical (f020fab3) on both. Same source, no content change.
  2. Green on both prior SHAs of this stack. d7504279 and 3664422e both ran this test with integration=success, 0 failures.
  3. Passes locally on the exact amended commit. go test -tags='integration test' ./tests/integration/evm/jsonrpc/... -run TestIndexerDisabledLookupUnavailablePASS (36.28s) at c02ad4f4.
  4. Untouched by this stack. No commit here modifies tests/integration/evm/jsonrpc/, the indexer, or the EVM tx-submission path. The two EVM-adjacent files in this branch (app/evm/ante_evmigration_fee_test.go, tests/integration/evm/mempool/evmigration_zero_signer_test.go) are in different packages and are the enable_migration default-false repairs.

Mechanism. exceeds block gas limit on a single legacy tx against a freshly-started node is a startup/consensus-param race: the test's tx is submitted before the node has the intended block gas limit in place. Load-dependent, hence intermittent under a loaded runner. Pre-existing condition in TestIndexerDisabledLookupUnavailable, unrelated to migration continuity.

Not silently retried. I re-ran only after establishing 1–4 above. Worth a separate hardening ticket for that test — it should wait for consensus params before submitting — but it is out of scope for this PR and I have not touched it.

Final status on c02ad4f4

cli-help-smoke  success
integration     success
simulation      success
system          success
unit            success

@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

⚠️ Release blocker found during upgrade-path verification — audit ConsensusVersion 2→3 has no handler to carry it

Verified live network state before planning the rehearsal, and it surfaced a blocker in this PR. Flagging before anyone merges.

Measured network state

Network chain_id app_version on-chain audit
Mainnet lumera-mainnet-1 1.12.0 (pre-EVM) v2
Testnet lumera-testnet-2 1.20.1 v2

Mainnet confirmed by 4 independent RPCs (polkachu, stakerhouse, ibs.team, linknode); module versions read from each chain's LCD.

The problem

This PR raises x/audit ConsensusVersion 2 → 3 and correctly registers the migration (module.go:103, RegisterMigration(types.ModuleName, 2, NewMigrateV2ToV3())).

But RunMigrations only fires from an upgrade handler, and this stack adds no new handler. Its entire diff to app/upgrades/ is comment-only:

app/upgrades/v1_20_0/upgrade.go      | 5 +++--
app/upgrades/v1_20_0/upgrade_test.go | 2 +-

Both v1.20.0 and v1.20.1 have already executed on testnet and will never run again.

  • Testnet: on-chain audit stays at v2 while this binary declares 3, with no migration path between them. Testnet cannot take this release as-is.
  • Mainnet: still at 1.12.0, so v1.20.0 has not run yet and would carry audit 2→3 as part of the EVM bring-up. Mainnet is accidentally fine — which is precisely why a mainnet-only rehearsal would have missed this.

Required fix

Add a v1_20_2 (or v1_21_0) handler whose job is to run RunMigrations so the audit bump lands legally. No StoreUpgrades needed. The migration is a genuine state no-op — NewMigrateV2ToV3 returns nil, legacy reports decode with empty current_submitter and identity indexes start empty — so the handler is thin, but it has to exist.

Second, lower-severity item

The v1_20_0 comment change documents a real behavioral shift: enable_migration now defaults false. Since v1.20.0 already ran on testnet with the old default but has not run on mainnet, the same binary will produce different evmigration params on the two networks. Before rollout we should read testnet's live params and, if migration is enabled there, submit an explicit MsgUpdateParams rather than relying on a code default to fix an already-migrated chain.

(Standing rule this PR already follows: never change an executed handler's logic — comments only. Any behavior change belongs in a new handler.)

Also worth fixing separately

The official mainnet endpoints are unhealthy: rpc.lumera.io does not respond and lcd.lumera.io returns 502, while community RPCs serve fine. Not a consensus issue — the chain is producing blocks — but our own endpoints should be up before an upgrade window when operators are watching them.

Full plan (including the mainnet 1.12.0 → EVM jump, which has never been rehearsed) is written up separately and I'll share it with the team.

…ion 2->3

RELEASE BLOCKER FIX
-------------------
This stack raises x/audit ConsensusVersion 2 -> 3 and registers the 2->3
migration, but RunMigrations only executes from inside an upgrade handler and
no new handler was added. Verified live on 2026-07-30:

  lumera-testnet-2   app_version 1.20.1   audit module version 2
  lumera-mainnet-1   app_version 1.12.0   audit module version 2

Testnet had ALREADY executed both v1.20.0 and v1.20.1, so neither can run
again. Shipping without this, the binary declares audit 3 while committed
testnet state says 2, with no path between them.

Mainnet masked the defect: still at 1.12.0, it has not run v1.20.0 yet, so
the EVM bring-up would have carried the bump for free. A mainnet-only
rehearsal would have passed and shipped a broken testnet release.

WHAT THIS ADDS
--------------
app/upgrades/v1_20_2 exposes only UpgradeName = "v1.20.2". The upgrade is
wired with the shared standardUpgradeHandler, which runs RunMigrations and
nothing else. No StoreUpgrades by design: testnet already mounted the EVM
store keys in v1.20.0 and re-mounting an existing key is an error.

No bespoke handler logic. Every behavioral change belongs in the module
migration so a chain reaching this version by any path gets identical state.

TESTS
-----
TDD, RED confirmed first (the package did not compile until the handler
existed):

- TestAuditConsensusVersionHasCarryingUpgrade pins audit ConsensusVersion and
  asserts the newest registered upgrade is strictly newer than v1.20.1, which
  is already live on testnet. Mutation-verified: removing v1.20.2 from
  upgradeNames makes it fail.
- TestV1202IsRegisteredAndMigrationOnly asserts registration on mainnet,
  testnet and devnet chain-ids and that StoreUpgrade is nil.
- TestV1202IsRecognizedAsKnownUpgrade proves SetupUpgrades resolves the plan
  name to a real handler, i.e. a node built from this tree will not stop with
  "upgrade plan not registered".
- TestV1202UpgradeNameMatchesDirectory guards the UpgradeName != git tag trap.
- TestUpgradeNamesOrder updated for the new entry.

DEVNET REHEARSAL (Phase 1, testnet-shaped)
------------------------------------------
Ran on the canonical 5-validator devnet, FROM the real v1.20.1 release
artifact (sha256 a150df59..., tarball checksum verified against the published
release_checksum) TO a binary built from this commit (sha256 b0b88821...).

Pre-upgrade state matched live testnet exactly: audit v2 with the full EVM
stack present.

  gov proposal 1 ......... PASSED (4000000000000 yes, 0 no)
  upgrade boundary ....... height 185, "UPGRADE v1.20.2 NEEDED"
  binary swap ............ a150df59... -> b0b88821... on all 5 validators
  resume ................. height 187
  q upgrade applied ...... height 185
  module_versions ........ audit v2 -> v3; every other module unchanged
  audit params ........... readable, no corruption
  bank send .............. code 0

Risks
-----
Adds a new upgrade name; no store changes, no proto change, no state
mutation beyond the module version bump. The migration itself is a no-op
(NewMigrateV2ToV3 returns nil; legacy reports decode with empty
current_submitter and identity indexes start empty).

Rollback
--------
Revert. Because the upgrade has not executed on any live network, reverting
pre-activation is safe.

Observability
-------------
standardUpgradeHandler logs upgrade start, migration completion and success.

Verification
------------
make lint .............................. 0 issues
go test -tags=test ./app/... ........... PASS
go test ./x/... ........................ PASS
make integration-tests NOCACHE=1 ....... PASS
git diff --check ....................... clean
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Phase 0 complete + Phase 1 devnet rehearsal PASSED

1a64adbe adds the v1.20.2 migration carrier that closes the audit ConsensusVersion 2→3 blocker reported above, and it has now been rehearsed end-to-end on a devnet shaped like live testnet.

Phase 1 rehearsal — testnet-shaped (v1.20.1v1.20.2)

Canonical 5-validator devnet. FROM was the real v1.20.1 release artifact, not a local build: tarball sha256 a2c9374b… verified against the published release_checksum, binary a150df59…. TO was built from this commit, b0b88821….

Pre-upgrade state matched live testnet exactly — audit v2 with the full EVM stack (vm, erc20, feemarket, precisebank, evmigration) already present.

Gate Result
gov proposal 1 PASSED — 4,000,000,000,000 yes / 0 no
upgrade boundary height 185, UPGRADE "v1.20.2" NEEDED
binary swap a150df59…b0b88821… on all 5 validators
resume height 187
q upgrade applied v1.20.2 height: 185
module_versions audit v2 → v3; every other module unchanged
q audit params readable, no corruption
bank send code: 0

That is the exact transition that could not have happened without this commit.

Finding: the rehearsal independently reproduced the testnet param divergence

Post-upgrade, q evmigration params on the devnet returned enable_migration: true. Root-caused to genesis: the devnet was seeded by the v1.20.1 binary, whose InitGenesis wrote the old default. v1.20.2 is migration-only and deliberately does not touch params.

This is the same condition I measured on live testnet (enable_migration: true, migration_end_time 1790940497 = 2026-10-02). So it is confirmed from two independent directions, and it means:

A MsgUpdateParams setting enable_migration=false is required on testnet before rollout. The code default cannot retroactively fix an already-initialized chain. The Phase 4 sequence is therefore enabled → disabled first, then the controlled canary → open ramp — not a clean start from disabled.

CI: system job flake

The system check failed on this commit. It is the documented port-collision flake, not a regression. All three criteria from our CI-parity rules are met:

  1. Explicit collision in the logerr> listen tcp 127.0.0.1:39981: bind: address already in use, followed by --- FAIL: TestAuditEmptyActiveSetBootstrap_NonCompliantHostStaysPostponed and panic: Fail in goroutine after ... has completed.
  2. system passed on the immediately preceding commit c02ad4f4.
  3. The named test passes locally on this exact commitPASS (53.69s), binary sha256-verified as built from 1a64adbe.

Nothing in this commit touches systemtests, ports, or the audit bootstrap path; the diff is one new upgrade package plus its registration and tests.

Local gate on 1a64adbe

make lint ........................... 0 issues
go test -tags=test ./app/... ........ PASS
go test ./x/... ..................... PASS
make integration-tests NOCACHE=1 .... PASS
git diff --check .................... clean

Devnet torn down; no containers or state left behind.

The Phase 2 mainnet-shaped devnet rehearsal caught two defects in v1.20.2 that
no amount of testnet-shaped testing could have surfaced. Both would have taken
mainnet down at the upgrade height.

REHEARSAL SETUP
---------------
5-validator devnet built from the real v1.12.0 release artifact (tarball
sha256 f64f4a31... verified against the published release_checksum), with
devnet-genesis.json trimmed to the v1.12.0 module set so the chain was a
faithful mainnet replica:

  audit v2, 30 modules, evmigration/evm/erc20/feemarket/precisebank ABSENT

which is exactly what lumera-mainnet-1 reports today.

DEFECT 1 — no StoreUpgrades
---------------------------
v1.20.2 originally declared none, reasoning that testnet already mounted the
EVM store keys in v1.20.0 and that re-adding a mounted key is an error. Correct
for testnet, wrong for mainnet. Every validator crash-looped:

  panic: failed to load latest version: version of store evmigration mismatch
  root store's version; expected 155 got 0; new stores should be added using
  StoreUpgrades

Fixed by declaring the same five EVM store additions as v1.20.0/v1.20.1 and
routing v1.20.2 through the existing AddOnlyStoreLoader, which mounts only keys
missing from committed state and never deletes one. The declaration and the
add-only loader are a MATCHED PAIR — changing one without the other breaks
exactly one network while leaving the other green.

DEFECT 2 — migrations-only handler on a pre-EVM chain
-----------------------------------------------------
With the stores mounted, the next run got further and then panicked:

  panic: error initializing evm coin info: denom metadata aatom could not be
  found

standardUpgradeHandler runs RunMigrations and nothing else. A pre-EVM chain
also needs the v1.20.0 bring-up work: bank denom metadata upsert, Lumera EVM
param finalization, and InitEvmCoinInfo. Without it cosmos/evm falls back to
the upstream atom denom, which does not exist on Lumera.

Fixed by making the handler state-driven, mirroring v1.20.1 so the two cannot
drift:

  EVM modules absent  -> delegate to the full v1.20.0 bring-up
  EVM modules present -> migrations only
  partially present   -> fail closed (not producible by any correct path)

Routing is on STATE, never chain-id, so a network arriving by an unexpected
path still converges on the same result.

EVIDENCE — third run, all gates green
-------------------------------------
  q upgrade applied v1.20.2 ...... height 114
  validators ..................... 200,200,200,200,201 (lockstep)
  module_versions ................ 30 -> 35 modules
  audit .......................... v2 -> v3
  evm/erc20/feemarket/precisebank/evmigration ... all now present
  q evm params ................... evm_denom "ulume" (NOT aatom)
  q evmigration params ........... enable_migration FALSE
  q audit params ................. readable, uncorrupted
  bank send ...................... code 0

enable_migration=false on the mainnet path is the security-relevant result: it
confirms a chain arriving from 1.12.0 gets the safe default, while testnet
(which ran v1.20.0 under the old default) keeps enable_migration=true in
committed state and needs an explicit MsgUpdateParams before rollout.

TESTS
-----
Both defects are now regression-guarded in v1_20_2_store_test.go, written
RED-first (StoreUpgrades was undefined until the fix):

- TestV1202MountsEVMStoresForMainnetOneHop asserts all five store keys are
  declared on every chain-id, and that nothing is deleted or renamed.
- TestV1202UsesAddOnlyStoreLoader asserts the loader pairing, with adaptive
  mode off so the path cannot depend on an env flag.

TestV1202IsRegisteredAndMigrationOnly was renamed to TestV1202IsRegistered and
its StoreUpgrade==nil assertion removed — that assertion encoded the exact
wrong premise this rehearsal disproved. The rename keeps the mistake visible
in history rather than silently deleting it.

RISKS
-----
Adds store mounts on a path that previously declared none. Mitigated by the
add-only loader (no-op when keys exist) and by the rehearsal covering both
arrival shapes. No proto change, no new state keys, no migration beyond the
audit module-version bump.

ROLLBACK
--------
Revert. The upgrade has not executed on any live network, so pre-activation
revert is safe.
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Correction: testnet should KEEP enable_migration=true — my earlier recommendation was wrong

In an earlier comment I flagged that live testnet has enable_migration=true and recommended submitting a MsgUpdateParams to set it false before rollout. That recommendation was wrong and is withdrawn. Matee caught it.

Migration being open is the point of this release. The continuity work exists to make migration safe, not to switch it off. Closing a working testnet migration would have been a self-inflicted regression with no upside.

What the code actually says

x/evmigration/keeper/ante.go:123:

if !params.EnableMigration { return types.ErrMigrationDisabled }
if len(params.CanaryLegacyAddresses) == 0 { return nil }   // <- empty = ALLOW ALL

An empty allowlist is allow-all, not deny-all. That gives three states:

EnableMigration CanaryLegacyAddresses Meaning
false any closed — mainnet's post-upgrade default
true empty open — testnet today
true non-empty canary — opt-in narrowing

Canary is a narrowing of an already-open network, entered by adding addresses. It is not a stage every network must pass through, and an open network does not need to be closed to reach it.

So the testnet/mainnet divergence is benign — and correct for each

  • Testnet stays open and keeps migrating across the upgrade. This PR lands the audit 2→3 migration, the ante-level canary enforcement and the identity-continuity fixes underneath a live migration flow, with no interruption and no params change.
  • Mainnet arrives closed, because it runs v1.20.0's bring-up for the first time under the new default. Correct posture for a chain that has never had migration enabled — governance opens it deliberately.

The only real requirement was that this asymmetry be known and intentional rather than discovered in production. It now is, and it is test-pinned.

Test added

x/evmigration/keeper/canary_semantics_test.go pins all three states, so nobody "hardens" the empty-list case into a deny and silently breaks every open network at the next upgrade:

  • TestCheckMigrationActivationEmptyCanaryAllowsEveryone — empty canary admits any address; EnableMigration=false dominates the allowlist; non-empty canary restricts to listed addresses.
  • TestCanaryIsOptIn — documents closed / open / canary as a three-state model.

Both pass. The revised Phase 4 plan is now "upgrade testnet, change no params, verify migrations continue across the boundary" rather than the disable-then-ramp sequence I originally wrote.

…is ALLOW ALL

Locks down the three-state model that decides whether a live network keeps
migrating across this upgrade.

WHY
---
CheckMigrationActivation (keeper/ante.go:123) reads:

	if !params.EnableMigration        { return ErrMigrationDisabled }
	if len(params.CanaryLegacyAddresses) == 0 { return nil }   // ALLOW ALL

An empty allowlist means allow-all, NOT deny-all. Combined with
EnableMigration=true that is "migration fully open", which is exactly the state
lumera-testnet-2 is in today (verified live 2026-07-30: enable_migration=true,
migration_end_time 1790940497 = 2026-10-02, no canary addresses set).

CORRECTION
----------
I had recommended flipping testnet to enable_migration=false before rollout.
That recommendation was wrong and is withdrawn. Migration being open is the
POINT of this release; the continuity work exists to make migration safe, not
to switch it off. Closing a working testnet migration would have been a
self-inflicted regression with no upside.

The resulting three states:

	EnableMigration=false                    -> closed  (mainnet post-upgrade default)
	EnableMigration=true,  canary empty      -> open    (testnet today, unchanged)
	EnableMigration=true,  canary non-empty  -> canary  (opt-in narrowing)

Canary is a NARROWING of an already-open network, entered by ADDING addresses.
It is not a stage every network must pass through, and an open network does not
need to be closed first to reach it.

The testnet/mainnet asymmetry is therefore real but benign, and is the correct
outcome for each: testnet stays open and keeps migrating while this upgrade
lands the audit 2->3 migration, the ante-level canary enforcement and the
identity-continuity fixes underneath it; mainnet arrives closed because it runs
v1.20.0's bring-up for the first time under the new default, which is the right
posture for a chain that has never had migration enabled.

TESTS
-----
- TestCheckMigrationActivationEmptyCanaryAllowsEveryone: empty canary admits any
  address and does not discriminate between addresses; EnableMigration=false
  dominates even a listed address; a non-empty canary admits listed and rejects
  unlisted with ErrMigrationNotCanary.
- TestCanaryIsOptIn: documents closed/open/canary as a three-state model so the
  operational meaning of each params combination is unambiguous.

These exist so nobody "hardens" the empty-list case into a deny and silently
breaks every open network at the next upgrade.

RISKS
-----
Test-only. No production code touched.

VERIFICATION
------------
make lint ......................... 0 issues
go test ./x/evmigration/... ....... PASS
go test ./x/... ................... PASS
go test -tags=test ./app/... ...... PASS
git diff --check .................. clean
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Phase 2b complete — two-hop rehearsed, and the two mainnet paths produce DIFFERENT state

Both mainnet-shaped paths are now rehearsed end-to-end on a 5-validator devnet built from the real v1.12.0 release artifact (tarball sha256 f64f4a31… verified against the published release_checksum), genesis trimmed to the v1.12.0 module set — a faithful replica of lumera-mainnet-1: audit v2, 30 modules, no EVM stack.

Single-hop 1.12.0 → 1.20.2 Two-hop 1.12.0 → 1.20.1 → 1.20.2
exit code 0 0 (both hops)
q upgrade applied height 114 207, then 327
modules 30 → 35 30 → 35
audit 2 → 3 2 → 2 → 3
EVM stack all 5 mounted all 5 mounted
evm_denom ulume ulume
validators lockstep lockstep
enable_migration false true ⚠️
max_retained_state_entries 10000 absent ⚠️

Module state converges. evmigration params do not.

Why

evmigration params are written exactly once — by whichever binary first runs the v1.20.0 EVM bring-up, and that binary differs per path:

  • Single-hop: our branch binary runs the bring-up → new defaults (enable_migration=false, new field present).
  • Two-hop: the released v1.20.1 binary runs the bring-up → old defaults (enable_migration=true, and no max_retained_state_entries because that field did not exist in that release). v1.20.2 then runs migrations only and never rewrites params.

The absent max_retained_state_entries is the corroborating signal — independent of enable_migration, and it confirms the params blob was authored by the older binary and left untouched.

Not a defect in either path. It is the direct consequence of "params are initialized once, by the binary that performs the bring-up".

Recommendation: SINGLE-HOP for mainnet

  1. Deterministic params — the binary we ship is the one that writes them.
  2. Safe default lands automatically — mainnet arrives closed and opens by deliberate governance action. On the two-hop path mainnet would arrive with migration already open, as a side effect of an intermediate binary rather than a decision.
  3. New param fields populated rather than absent and resolved by fallback.
  4. One halt instead of two — half the operator coordination and exposure window.
  5. Equally proven — single-hop is not the less-tested option; both are green.

If two-hop is ever operationally required it is safe, but it is not complete without a follow-up MsgUpdateParams setting evmigration params explicitly. Don't rely on v1.20.2 to correct them — it runs migrations only, by design.

Testnet is unaffected

Testnet stays at enable_migration=true and keeps migrating across the upgrade. That's the correct posture — open migration is the point of the release. Semantics are now test-pinned in x/evmigration/keeper/canary_semantics_test.go (55c404ca).

The mainnet/testnet difference is intentional and correct for each network: mainnet opens deliberately, testnet never stops.

Rehearsal status

Phase Result
0 — v1.20.2 carrier
1 — testnet-shaped 1.20.1 → 1.20.2
2 — mainnet-shaped single-hop ✅ (caught 2 mainnet-fatal defects)
2b — mainnet-shaped two-hop ✅ (this comment)
3 — multi-SN functional gate ⏸ blocked on chain merge → artifact → SN #318

Written up in full at PHASE-2-PATH-COMPARISON.md.

@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Correcting myself, and flagging a release-blocking collision with #198

First, I was wrong. I said the multi-SuperNode gate was blocked on this PR merging, a chain artifact being cut, and #318 bumping its go.mod. That was the lazy answer — I saw go.mod pinning v1.20.0-rc3, saw SN CI red on unreleased protos, and concluded "blocked" without testing the obvious alternative.

Measured just now, not assumed:

go mod edit -replace github.com/LumeraProtocol/lumera=<local chain worktree>
go build ./...           -> OK (~35s)
go test  ./... -count=1  -> ZERO failures, including tests/integration/evmigration

The SuperNode repo already carries five replace directives. The entire SN suite passes against this branch today. Nothing needs to merge to test any of it. A merge/tag is only needed for the artifact operators install — every correctness question is answerable locally, first. #318's red CI is a go.mod pin artifact, not a defect, and it cannot be our gate because it stays red until the chain tags.


Release blocker: #198 and #199 both define app/upgrades/v1_20_2

Both branch off #196. Verified by diff against their common base:

#198 -> app/upgrades/v1_20_2/upgrade.go, upgrade_test.go
#199 -> app/upgrades/v1_20_2/upgrade.go, v1_20_2_recognized_test.go, v1_20_2_store_test.go

Both also register the upgrade in app/upgrades/upgrades.go. They will conflict on merge.

The problem is not the conflict — it's which side wins. #198's registration is:

case upgrade_v1_20_2.UpgradeName:
    return UpgradeConfig{
        Handler: standardUpgradeHandler(upgrade_v1_20_2.UpgradeName, params),
    }, true

No StoreUpgrade. That is exactly the implementation I shipped first, and the mainnet-shaped Phase 2 rehearsal killed it twice:

panic: version of store evmigration mismatch root store's version; expected 155 got 0
panic: error initializing evm coin info: denom metadata aatom could not be found

Every validator crash-looped on a faithful lumera-mainnet-1 replica. #199's version adds the five EVM store additions, the add-only store loader, and a state-driven handler that delegates to the v1.20.0 bring-up when the EVM stack is absent — and is green on both single-hop and two-hop mainnet paths.

So: if #198 merges after #199, or a conflict is resolved in #198's favour, we silently ship a mainnet-fatal upgrade handler. #197 stacks on #198 and inherits whichever wins.

This needs a decision before either merges — close #198 in favour of #199's implementation, or strip v1_20_2 from #198 and let #199 own it. I'm not going to resolve it unilaterally.


What is actually proven, and what is not

Status
Chain upgrade correctness (both shapes, both paths) ✅ Phases 0/1/2/2b
Static gates on #199 ✅ 5/5 CI
Local cross-repo build + SN suite ✅ zero failures
SuperNode history preservation ❌ unproven
Pending-SN migration per operator docs ❌ unproven
Validator + multisig migration, mainnet shape ❌ unproven
Everlight continuity (metrics/rewards/payouts) ❌ unproven
Audit module continuity across the boundary ❌ unproven
LEP-6 continuity ❌ unproven
Cascade / reporting / P2P post-migration ❌ unproven
AppHash equality across validators ❌ never asserted

Phases 0-2b proved the chain upgrades. They proved nothing about SuperNodes, Everlight, LEP-6, Cascade, or the operator docs. I should not have implied we were close to done.

That last row matters: height lockstep is not divergence-freedom. Two nodes can sit at the same height with different state. We should be asserting byte-equal AppHash across all five validators at N post-upgrade heights, and we currently assert it nowhere.


Plan

Full gate-by-gate plan written up in ROAD-TO-RELEASE-CONFIDENCE.md: one integration branch (#196 + #198 + #199 + #197), two devnet shapes (testnet replica and mainnet replica), ten gates each with a falsifiable pass criterion and required evidence — including negative controls so we can prove a test can detect a difference at all, and literal execution of #197's runbooks rather than paraphrase.

Order: resolve the #198/#199 collision → integration branch green → T-shape gates → M-shape gates → fix docs as bugs surface → only then cut the tag.

Found on a mainnet-shaped devnet while migrating a real fixture cohort: any
legacy account holding an ACTIVE governance deposit could not migrate.

buildGovernancePlan called proto.Clone on a govv1.Deposit. Deposit.Amount is
[]sdk.Coin, whose Amount is an sdkmath.Int wrapping *big.Int. gogoproto's
reflective table-merge descends into big.Int's unexported 'abs []big.Word',
finds no registered merger for big.Word, and panics:

  ERR panic recovered in runTx err="recovered: merger not found for type:big.Word
    gogoproto/proto.(*mergeInfo).computeMergeInfo   table_merge.go:662
    x/gov/types/v1.(*Deposit).XXX_Merge              gov.pb.go:212
    gogoproto/proto.Clone                            clone.go:52
    keeper.buildGovernancePlan                       migrate_retained.go:417
    msgServer.ClaimLegacyAccount                     msg_server_claim_legacy.go:106

The tx aborts so no state is corrupted, but the account stays unmigratable while
the deposit exists, and the operator-facing error names neither governance nor
deposits - undiagnosable in the field.

Likelihood is high: any account that has submitted a proposal whose deposit is
still held is affected, which includes the governance participants most likely
to migrate first.

Replaced both proto.Clone sites with an explicit cloneGovDeposit deep copy.
Coin.Amount is an immutable sdkmath.Int, so element-wise copy is a correct deep
copy and avoids reflection entirely.

Tests (x/evmigration/keeper/migrate_retained_clone_test.go):
- TestProtoCloneOnGovDepositPanics: RED test pinning that proto.Clone still
  panics upstream, so the helper is not 'simplified' back into it later.
- TestCloneGovDepositIsCorrectDeepCopy: value equality AND independence - a
  shallow copy would let later mutation leak into the plan's source record,
  which is what rollback/verification compares against.
- TestCloneGovDepositEdgeCases: nil Amount, empty slice, and a multi-word
  big.Int (2^200) - the exact shape that makes the reflective walk touch
  big.Word at all.

Verified live: rebuilt binary sha256 019e427ed4cbc433..., hot-swapped into the
running mainnet-shaped devnet, restarted, re-ran migration -> zero
'merger not found' occurrences in validator logs. ./x/evmigration/... and
-tags=test ./app/... both green.

Known follow-up (NOT fixed here): with the panic gone the same account now
fails with 'stale governance deposit source for proposal 2: value changed' from
the plan's staleness guard. Fails closed, so it is safe, but it needs its own
investigation - tracked in BUGS-AND-FINDINGS.md as BUG-17.
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Found and fixed a real chain bug during devnet migration testing

Running the full devnet-evmigration-prepare fixture cohort (19 legacy accounts with delegations, unbondings, redelegations, third-party withdraw addresses, authz, feegrants, claims, actions, plus multisig and permanent-locked fixtures) against a mainnet-shaped devnet — real v1.12.0 artifact, genesis trimmed to the v1.12.0 module set, upgraded single-hop to v1.20.2 — surfaced a defect that no unit test could have caught.

The bug

buildGovernancePlan called proto.Clone on a govv1.Deposit. That panics:

ERR panic recovered in runTx err="recovered: merger not found for type:big.Word
  gogoproto/proto.(*mergeInfo).computeMergeInfo   table_merge.go:662
  x/gov/types/v1.(*Deposit).XXX_Merge             gov.pb.go:212
  gogoproto/proto.Clone                           clone.go:52
  keeper.buildGovernancePlan                      migrate_retained.go:417
  keeper.buildRetainedStatePlan                   migrate_retained.go:83
  msgServer.ClaimLegacyAccount                    msg_server_claim_legacy.go:106

Deposit.Amount is []sdk.Coinsdkmath.Int*big.Int → unexported abs []big.Word. gogoproto's reflective table-merge descends into that slice, finds no registered merger for big.Word, and panics.

Impact

Any legacy account holding an active governance deposit could not migrate. The tx aborts, so there is no state corruption — but:

  • the account is permanently unmigratable while the deposit exists, and
  • the operator-facing error is merger not found for type:big.Word, which mentions neither governance nor deposits. Undiagnosable in the field.

Likelihood is high, not theoretical: any account that has submitted a proposal whose deposit is still held is affected. On mainnet that includes the governance participants most likely to migrate early.

Fix

Replaced both proto.Clone sites with an explicit cloneGovDeposit deep copy. Coin.Amount is an immutable sdkmath.Int, so element-wise copy is correct and avoids reflection entirely.

Tests (x/evmigration/keeper/migrate_retained_clone_test.go)

  • TestProtoCloneOnGovDepositPanicsRED test pinning that proto.Clone still panics upstream, so the helper is not "simplified" back into it later.
  • TestCloneGovDepositIsCorrectDeepCopy — value equality and independence. A shallow copy would let a later mutation leak into the plan's source record, which is exactly what rollback/verification compares against.
  • TestCloneGovDepositEdgeCases — nil Amount, empty slice, and a multi-word big.Int (2^200), the precise shape that makes the reflective walk touch big.Word at all.

Verification

Not just unit-green — verified end to end on the live chain:

  • rebuilt lumerad (sha256 019e427ed4cbc433…), hot-swapped into the running mainnet-shaped devnet, restarted all 5 validators
  • re-ran migration → zero merger not found occurrences in validator logs (grep -c = 0)
  • ./x/evmigration/..., -tags=test ./app/..., ./x/... all green; make lint 0 issues

Migration run result: 16/19, with two high-risk paths confirmed working

deleted legacy key: pre-evm-val1-msig  (migrated to pre-evm-val1-msig-new-msig)
deleted multisig signer key: pre-evm-val1-msig-signer-1 / -2 / -3
deleted legacy key: pre-evm-val1-plock (migrated to evm-val1-plock)

The user multisig (2-of-3 secp256k1 composite) and permanent-locked fixtures both migrated cleanly. The 3 remaining failures:

Failure Verdict
merger not found for type:big.Word this bug — fixed
stale governance deposit source for proposal 2: value changed surfaced behind it, open, under investigation
legacy address is a validator operator; use MsgMigrateValidator instead correct rejection — validators go through migrate-validator

Known follow-up, not fixed here

With the panic gone, the same account now fails cleanly with stale governance deposit source for proposal 2: value changed from the plan's precondition guard. It fails closed, so it is safe. But nothing mutated that deposit between plan-build and apply in this run, so it looks like a false staleness positive. I am not claiming it is a defect until I have a focused reproduction that distinguishes "guard is over-strict" from "something genuinely rewrites the deposit mid-tx". It blocks the same population this bug did, so it is the next investigation.

Two defects in the retained-state staleness guard, both found on a
mainnet-shaped devnet while migrating a real fixture cohort. Each one
independently prevented a legacy account holding a governance deposit from
ever migrating.

1) proto.Equal is unreliable for Coin-bearing messages

verifyCollectionValue compared the re-read on-chain value against the plan's
expectation with proto.Equal. gogoproto cannot compare sdkmath.Int:

    proto: don't know how to compare 2000000000

so proto.Equal returns FALSE for two byte-identical gov Deposits. Migration
failed with

    stale governance deposit source for proposal 2: value changed

on a deposit nothing had touched. Same reflection family that panics outright
in proto.Clone.

Replaced with marshalled-bytes comparison (protoBytesEqual), which is the
deterministic, consensus-relevant notion of "unchanged" - it is what the store
actually holds - and avoids reflection entirely. Applied to the authz-grant and
destination-vote comparisons too, which had the same exposure.

2) typed-nil interface defeated the optional-destination check

verifyOptionalCollectionValue took `expected proto.Message` and tested
`expected == nil` to mean "the plan saw no destination entry". Callers pass a
concrete typed pointer, and a nil *govv1.Deposit boxed in an interface is NOT
== nil, so the "absent is fine" branch never ran. The guard fell through to the
comparison path and failed on ErrNotFound:

    stale governance deposit destination for proposal 2: collections: not found:
    key '("2","lumera1k7del...")' of type ...gov.v1.Deposit

It demanded the destination exist, then failed because it did not - blocking
every account whose target address had no pre-existing deposit, i.e. the normal
case. Added isNilMessage, which detects typed-nil via reflection.

Both fail closed, so no state was corrupted. But they permanently blocked
legitimate migrations and the errors pointed at concurrent mutation that never
happened.

Tests (migrate_retained_staleness_test.go):
- TestProtoEqualIsBrokenForGovDeposits: characterization test pinning the
  upstream defect, so the byte comparison is not "simplified" back later.
- TestProtoBytesEqualFixesTheFalseStaleness: identical deposits compare equal,
  including via cloneGovDeposit.
- TestProtoBytesEqualDetectsRealChanges: NEGATIVE CONTROL - a comparator that
  always returned true would also "fix" the bug while destroying the guard.
  Covers changed amount, depositor, proposal id, extra denom, emptied amount.
- TestProtoBytesEqualNilVsEmptyCoins: nil and empty Coins marshal identically
  and must not trip the guard.
- TestIsNilMessageCatchesTypedNil: the typed-nil trap for both Deposit and Vote,
  asserting the raw `== nil` is false while isNilMessage is true.

Verified live on the mainnet-shaped devnet (v1.12.0 -> v1.20.2 single-hop, 5
validators, 5 supernodes, 19-account fixture cohort):

    before: 16/19 migrated, blocked by the panic
    then:   16/19, blocked by "source: value changed"
    then:   16/19, blocked by "destination: not found"
    now:    17/19 - 17 migration records on chain

The 2 remaining failures are validator operators correctly rejected with
"use MsgMigrateValidator instead".

Gates: ./x/evmigration/... green, -tags=test ./app/... green,
-tags='integration test' ./tests/integration/evmigration/... green,
make lint 0 issues.
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Two more defects in the same guard family — found by continuing past the first green run

Following the proto.Clone panic fix, the same account failed twice more. Each fix revealed the next, three deep. All three were in the retained-state staleness guard, all three fail closed, and all three permanently blocked legitimate migrations while reporting causes that did not exist.

BUG-17 — proto.Equal cannot compare sdkmath.Int

stale governance deposit source for proposal 2: value changed

verifyCollectionValue compared the re-read on-chain value against the plan's expectation using proto.Equal. Proved empirically that this is wrong:

proto: don't know how to compare 2000000000
=> proto.Equal(a, b) == false for two BYTE-IDENTICAL gov Deposits

Same reflection family that panics in proto.Clone — but here it fails silently and returns the wrong answer, which is worse. This was not a stale read; the comparator itself is broken. Nothing had touched proposal 2's deposit.

Fix: compare marshalled bytes (protoBytesEqual) — the deterministic, consensus-relevant notion of "unchanged", and exactly what the store holds. Applied to the authz-grant and destination-vote comparisons too, which had identical exposure.

BUG-19 — typed-nil interface defeated the optional-destination check

stale governance deposit destination for proposal 2: collections: not found:
key '("2","lumera1k7del...")' of type ...cosmos.gov.v1.Deposit

verifyOptionalCollectionValue took expected proto.Message and tested expected == nil to mean "the plan saw no destination entry". Callers pass a concrete typed pointer, and a nil *govv1.Deposit boxed in an interface is not == nil — so the "absent is fine" branch never ran. The guard fell through to comparison and failed on ErrNotFound: it demanded the destination exist, then failed because it did not.

Blast radius: the normal case. Every legacy account whose target address had no pre-existing deposit. Only masked because BUG-16/17 aborted the tx first.

Fix: isNilMessage detects typed-nil via reflection.

Live progression — one account, three sequential defects

State Result
baseline 16/19 — panic: merger not found for type:big.Word
+ BUG-16 fix 16/19 — source: value changed
+ BUG-17 fix 16/19 — destination: not found
+ BUG-19 fix 17/19 — 17 migration records on chain

The 2 remaining failures are validator operators correctly rejected with use MsgMigrateValidator instead — that is the validator-migration path, not a defect.

Tests

  • TestProtoEqualIsBrokenForGovDepositscharacterization test pinning the upstream defect, so the byte comparison is not "simplified" back later. If gogoproto ever fixes this, the test fails and tells us.
  • TestProtoBytesEqualFixesTheFalseStaleness — identical deposits compare equal, including via cloneGovDeposit.
  • TestProtoBytesEqualDetectsRealChangesnegative control. A comparator that always returned true would also make the symptom vanish while destroying the guard entirely. Covers changed amount, depositor, proposal id, extra denom, emptied amount.
  • TestProtoBytesEqualNilVsEmptyCoins — nil and empty Coins marshal identically and must not trip the guard.
  • TestIsNilMessageCatchesTypedNil — the typed-nil trap for both Deposit and Vote, asserting the raw == nil is false while isNilMessage is true. (Worth noting: testify's require.NotNil uses reflection and sees through typed-nil, so it cannot demonstrate this — the raw comparison has to be asserted directly.)

Gates

./x/evmigration/... green · -tags=test ./app/... green · -tags='integration test' ./tests/integration/evmigration/... green · make lint 0 issues · verified live on the mainnet-shaped devnet.

Reviewer note

The general lesson, and why I kept going after the first fix went green: fail-closed is not the same as correct. A guard that cannot be satisfied is an outage with good manners. Unit tests passed throughout all three defects — only a mainnet-shaped devnet with a real fixture cohort (19 legacy accounts with delegations, unbondings, redelegations, third-party withdraw addresses, authz, feegrants, claims, actions, multisig and permanent-locked) surfaced them. Stopping at the first green run would have shipped two of these.

…migration

Closes a coverage hole where nothing - unit or devnet - proved that a
supernode's evidence history and metrics survive an identity migration.

The gap was vacuous-green in both places:
  - devnet fixtures report evidence=0 and has_metrics=false on every
    supernode, so "evidence preserved" passed because there was nothing
    to preserve;
  - the keeper fixture rawTestSuperNode() carries no evidence either.

This test supplies the non-empty state both lack: three evidence entries with
distinct reporters, types and heights, plus metrics with a non-zero
ReportCount.

Contract note (corrected after reading the existing test): the primary
supernode record is VALIDATION-ONLY in ApplyIdentityMigrationPlan and is
deliberately NOT moved -

    // Primary/account/history are owned by PR196 and are validation-only here.
    require.Equal(t, sourcePrimaryRaw, store.Get(types.GetSupernodeKey(source)))
    require.Nil(t, store.Get(types.GetSupernodeKey(destination)))

so the correct invariant at this layer is not "evidence moves" but "evidence is
left byte-identical at the source, untouched" while the continuity state the
plan does own (metrics, distribution, payout history) relocates. The test also
asserts no record is conjured at the destination.

Asserted:
  - evidence count, reporter, type and height unchanged, in order
  - metrics follow the identity with exact values, ReportCount included
  - metrics do NOT remain at the source (duplicate state would double-count
    in audit/payout aggregation)

Proven non-vacuous by mutation testing (evidence/g4_mutation_evidence.sh),
3/3 mutants detected:
  - metrics move dropped              -> "metrics must follow the migrated identity"
  - metrics ReportCount zeroed        -> "report count must be preserved"
  - one evidence entry silently dropped -> "should have 3 item(s), but has 2"

Baseline passes before and after; the script restores the source tree and
re-verifies. Note a first attempt at the third mutant injected into
ApplyIdentityMigrationPlan where sourceSN is out of scope: it failed to
compile, which is INCONCLUSIVE rather than a pass, and was fixed to inject
into BuildIdentityMigrationPlan.

Gates: ./x/supernode/... green, ./x/evmigration/... green,
golangci-lint ./x/supernode/... 0 issues.
@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

G1b (AppHash equality) and G2 evidence/metrics — two more gates closed

G1b — AppHash equality was NEVER asserted anywhere

$ grep -ri app_hash devnet/scripts/ Makefile.devnet
(no matches)

Every upgrade rehearsal to date verified only that blocks were produced after the upgrade. That proves liveness, not agreement — a validator can keep producing blocks while holding divergent state, and CometBFT only halts once the divergence is actually voted on. So "the upgrade succeeded" has never meant "all validators computed the same state".

Added evidence/g1b_apphash_equality.sh (single height) and evidence/g1b_sweep.sh (window). Both assert all 5 validators report an identical app_hash and last_block_id.hash.

At the upgrade block itself (2753) — where a non-deterministic handler would diverge:

supernova_validator_1..5:
  app_hash = 6A82550055D89B30825BD618DA3C6278B4934CEB4C69E6436D4187E82FEB44AD
RESULT: PASS — all 5 validators report identical app_hash at height 2753

Sweep across the critical window — 9/9 PASS, 0 fail, 0 inconclusive:

Height Why
2653 100 blocks pre-upgrade (baseline)
2752 last pre-upgrade block
2753 the upgrade block
2754, 2755, 2763 migrations settling
2853 100 blocks post-upgrade
4285 account-migration activity
6847 SN-account migration activity

Non-vacuity proven: --self-test injects a divergent hash and confirms the comparison fails (good — a divergent app_hash IS detected). An unreachable validator returns exit 2 = INCONCLUSIVE, never PASS — an absent node is not agreement.

This establishes that the v1_20_2 handler, the store upgrades, and every migration executed on the devnet are deterministic across independent nodes. That is the invariant that protects mainnet from a consensus halt, and until now it was assumed rather than measured.

G2 — evidence/metrics preservation was vacuous in TWO places

Not just the devnet (evidence=0, has_metrics=false on every SN) — the keeper fixture rawTestSuperNode() carries no evidence either. So nothing anywhere, unit or devnet, proved evidence survives an identity migration.

TestIdentityMigrationPreservesEvidenceAndMetrics supplies what both lack: 3 evidence entries with distinct reporters/types/heights, plus metrics with a non-zero ReportCount.

Contract corrected by reading the code. My first draft asserted evidence moves to the destination — and failed. The existing test states the real contract:

// Primary/account/history are owned by PR196 and are validation-only here.
require.Equal(t, sourcePrimaryRaw, store.Get(types.GetSupernodeKey(source)))
require.Nil(t, store.Get(types.GetSupernodeKey(destination)))

ApplyIdentityMigrationPlan deliberately does not move the primary record. So the correct invariant at this layer is "evidence is left byte-identical at the source, untouched" while the continuity state the plan owns (metrics, distribution, payout history) relocates — plus "no record is conjured at the destination".

Mutation-tested, 3/3 detected (evidence/g4_mutation_evidence.sh):

Mutant Caught by
metrics move dropped metrics must follow the migrated identity
metrics ReportCount zeroed report count must be preserved
one evidence entry silently dropped should have 3 item(s), but has 2

Method note worth generalising: the third mutant initially injected into ApplyIdentityMigrationPlan, where sourceSN is out of scope — it failed to compile. A non-compiling mutant is INCONCLUSIVE, not detected. I fixed the injection point rather than counting it. A mutation suite that silently scores build failures as "caught" proves nothing.

Where the branch stands

5 of 9 release gates now green, each backed by a falsifiable check plus a negative control:

Gate Status
G1 upgrade correctness (both shapes, both paths) 🟢
G1b AppHash equality 🟢 9/9 heights
G2 SN history — validator path + SN-account path + evidence/metrics 🟢 11 mutants
G9 collision · G10 integration gate 🟢
G3–G8 (Everlight, audit, LEP-6, Cascade continuity) 🔴 unproven

Gates on this commit: ./x/supernode/... green, ./x/evmigration/... green, -tags='integration test' ./tests/integration/evmigration/... green, make lint 0 issues.

Not a ship recommendation. G3–G8 need running supernode daemons and remain untested; the devnet's SNs are all POSTPONED and were never started. What is established is that the migration and upgrade core is deterministic, history-preserving, and free of the three defects fixed on this branch.

@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

Operator runbook addendum — every trap that actually bit during rehearsal

Full mainnet-shaped rehearsal is done (v1.12.0 → v1.20.2 single-hop, 5 validators, 19-account legacy fixture). Beyond the three chain bugs fixed on this branch, the rehearsal surfaced a set of operator-facing traps that will cost downtime if they aren't documented. None of them are hypothetical — each one bit me.

Written up as OPERATOR-RUNBOOK-ADDENDUM.md. The items I'd most want a mainnet operator to see:

1. Release order is rigid

chain merge → chain tag → SN go.mod bump → SN tag → operators upgrade → params

SuperNode v2.6.4 cannot build against any released chain — it needs TargetAccountMappings, which only exists in the chain release carrying #199. Tag the SN first and you publish something nobody can build. Also worth a pre-announce check that assets actually published: v2.6.0-rc1 currently has zero assets, so it installs as a 404 for every operator.

2. Migrating an SN account is not the whole job

migrate-account.sh updates the chain, not the daemon config. After migrating, key_name/identity in config.yml still name the old address, and the chain does not resolve a supernode by a superseded account — provenance records history, it does not make old addresses resolvable:

$ lumerad query supernode get-supernode-by-address <OLD address>
rpc error: NotFound desc = supernode not found: key not found

Good news, and worth crediting: this fails loudly. The daemon's ConfigVerifier refuses to start with an actionable message, and sn-manager independently blocks the v2.6.0-boundary upgrade unless supernode.evm_key_name is set. I initially wrote this up as silent breakage and retracted that after reading the source — the defence in depth is already there and works.

3. key_name is ambiguous without the keyring directory

The supernode daemon has its own keyring (~/.supernode/keys). The same key name resolves to two different addresses depending on which keyring is read. The devnet had been provisioned for months with supernodes that could never start, because registration used the validator-keyring address while config.yml named the daemon-keyring one — all 5 nodes, 5 distinct unregistered identities, predating any migration work.

Any instruction that says "use key X" must state which keyring.

4. You cannot export/import a migrated key between keyrings

Once migrated the key is eth_secp256k1, and import rejects it:

failed to decrypt private key: unmarshal to types.PrivKey failed after 4 bytes
  (unrecognized prefix bytes ...)

Export succeeds, so the armor looks valid — the failure is at import. Generate the identity in the daemon keyring directly and point the chain at it instead of attempting a transfer.

5. Governance traps when opening the migration window

  • Deposit: underfunded proposals sit silently in DEPOSIT_PERIOD and never reach voting, with no error.
  • Vote: a hand-rolled all-validator vote loop can silently get 4/5 when one key is offline or multisig (cannot sign with offline keys). The proposal still passes, hiding the failure. Always check gov tally — never infer success from "proposal passed".

6. Parameter semantics are easy to invert

enable_migration canary_legacy_addresses Effect
false any Closed
true empty OPEN to everyone (not deny-all)
true non-empty Canary — only listed

An empty allowlist with migration enabled is allow-all.

7. Verifying an upgrade: block production ≠ agreement

A validator can keep producing blocks while holding divergent state; CometBFT only halts once the divergence is voted on. Assert AppHash equality across validators at the upgrade height — more than one distinct hash is consensus divergence even with every node producing. An unreachable validator is inconclusive, not a pass.


Gate status on this branch: 5 of 9 green (G1 upgrade correctness, G1b AppHash equality, G2 SN history, G9 collision, G10 integration), each with a negative control or mutation suite.

G3–G8 (Everlight, audit continuity, LEP-6, Cascade) remain unproven — they need a live supernode, and per item 3 above the devnet's supernodes have never been able to start. That is a pre-existing provisioning defect, now root-caused, but it means those gates are blocked rather than passing. I'd treat this branch as production-gated on the migration/upgrade core only.

@mateeullahmalik

Copy link
Copy Markdown
Contributor Author

G3 + G5: supernodes brought up, audit continuity PROVEN across migration

Two updates: the supernode blocker is resolved (without touching chain state), and that unlocked the first real proof of audit-path continuity.

G3 — supernodes are running

The devnet's supernodes had never started — all 5 sat POSTPONED@20. Root cause was not the migration:

The supernode daemon uses its own keyring (~/.supernode/keys), separate from the validator keyring. supernova_supernode_N_key resolved to two different addresses depending on which keyring was read. Registration used the validator-keyring address; config.yml named the daemon-keyring one, which was never registered on chain — not as a current account, not in any provenance chain. Pre-existing, all 5 nodes, predating the EVM upgrade.

Fixed config-only, no chain change. The key holding each migrated SN account already existed as evm-supernova_supernode_N_key in the validator keyring:

evm-supernova_supernode_1_key   -> lumera1u6876dakryhv8tram3w8xw0gzurj5x5xyrgjep
registered SN account (.0.11)   -> lumera1u6876dakryhv8tram3w8xw0gzurj5x5xyrgjep

So no MsgUpdateSupernode was needed. Mapping verified before editing, config.yml backed up, nothing deleted, reversible. All 5 daemons now start, verification passes, and LEP-6 self-healing initialises with the migrated identity.

G5 — audit continuity across EVM migration 🟢 PASS

The invariant: after an SN account migrates legacy → EVM, the audit module must keep accepting that node's epoch reports under the new identity with no coverage gap. If migration broke the audit path, reports would stop at the migration height.

5/5 PASS:

--- validator 4 ---
  pre-migration account : lumera1reqry33udznmx2rc3df9m205evfhuu0dsydwru
  migrated account      : lumera1rp2hwqxfc23d4u2gzr56uqh8e4fwfapgqkhcgm
  reports: 21  epochs 523..543
  all reported under the MIGRATED account: True
  epoch gaps: none

~20 consecutive epochs per node, every report under the post-migration account, zero gaps. Live on-chain, actively advancing (INFO epoch report submitted {"epoch_id": 542}).

Non-vacuity: 2/2 mutants detected — injected coverage gap → coverage gap [(529, 531)]; report under old identity → 1 report(s) under an unexpected account.

Scope, stated plainly: this proves the host-report path end-to-end (daemon signature → audit acceptance → storage). It does NOT prove storage-challenge or storage-proof — every report carries storage_challenge_observations: [] and storage_proof_results: [] because no Cascade data exists to challenge. G6–G8 remain unproven.

Observation for the audit-module owner (not filed as a bug)

4/5 supernodes stay POSTPONED, and the cause is protocol logic, not config. Every health gate passes (selfHostCompliant ✅, disk 88.31 <= 90 ✅) but recovery needs a peer port-attestation. The code has an explicit anti-deadlock guard:

// Bootstrap exception: when the epoch's anchored active set is empty, no
// probers exist by construction, so the peer-port recovery rule below is
// unsatisfiable and would deadlock the chain (all SNs POSTPONED → 0
// probers → 0 peer reports → no SN can ever recover).
if ... len(anchor.ActiveSupernodeAccounts) == 0 { return true, nil }

It fires only at exactly zero. This devnet has one active supernode, so the exception doesn't apply — but a single active node cannot peer-attest for anyone but itself, leaving four healthy nodes permanently unrecoverable.

Not a mainnet defect (healthy active sets are >> 1), but a genuine recovery edge case at active-set size 1 — exactly the degraded state you'd most want to recover from. Question: should the exception trigger when the active set is too small to supply any peer prober (len(active) < 2, or < required_attestations + 1) rather than only at zero? The comment's reasoning applies verbatim at len == 1.

Not filed as a defect — it needs the module owner's intent on whether size-1 recovery is supported.

Gate status: 6 of 9

Gate Status
G1 upgrade correctness · G1b AppHash (9/9 heights) · G2 SN history (11 mutants) · G5 audit continuity (5/5, 2 mutants) · G9 · G10 🟢
G3 supernode bring-up 🟡 daemons up; POSTPONED root-caused to protocol logic
G4, G6–G8 (Cascade, LEP-6 storage-truth, storage challenges) 🔴 need Cascade data uploaded

Chain gates on e901b8c7: ./x/supernode/..., ./x/evmigration/..., integration suite green; make lint 0 issues.

…testing)

Class-A storage-truth fault accounting had NO test coverage. Mutation testing
proved it: forcing `isClassA = false` in updateNodeSuspicionHistoryFields - so
HASH_MISMATCH and RECHECK_CONFIRMED_FAIL are never counted - left the entire
storage-truth suite GREEN.

ClassACountWindow / LastClassAEpoch / CleanPassCount gate band escalation and
recovery. Without these assertions, a regression could let a node with repeated
hash mismatches (the strongest evidence of storage dishonesty) accumulate no
suspicion and recover as if clean, with nothing failing.

Adds two tests:

  TestUpdateNodeSuspicionHistoryFields_ClassAFaultAccounting
    HASH_MISMATCH and RECHECK_CONFIRMED_FAIL each must
      - increment ClassACountWindow (added to a pre-existing count, not replaced)
      - stamp LastClassAEpoch
      - reset CleanPassCount (recovery requires clean passes with no new Class A)
      - leave ClassBCountWindow untouched

  TestUpdateNodeSuspicionHistoryFields_ClassBDoesNotTouchClassAGates
    TIMEOUT_OR_NO_RESPONSE must increment ClassB counters ONLY and must not
    touch Class-A gates. The code comment states TIMEOUT-on-INDEX "must not
    reset Class-A recovery gates or increment ClassACountWindow"; that
    separation was previously unasserted in both directions.

Verified the new coverage is real - with the mutant injected the suite now
FAILS on both Class-A classes, and passes once restored:

  --- FAIL: ..._ClassAFaultAccounting/STORAGE_PROOF_RESULT_CLASS_HASH_MISMATCH
  --- FAIL: ..._ClassAFaultAccounting/STORAGE_PROOF_RESULT_CLASS_RECHECK_CONFIRMED_FAIL

Two fixture requirements worth noting for future tests here:
  - the counters live inside an `isFailure` branch keyed on BucketType /
    ArtifactClass, so a bare {ResultClass} result does not exercise them;
  - WindowStartEpoch must be fresh, otherwise the stale-window reset zeroes
    ClassACountWindow before the increment and masks the assertion.

Gates: ./x/audit/... green, golangci-lint ./x/audit/... 0 issues.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant