From c14cf42a3715254da2a93ca91e620f931784a25b Mon Sep 17 00:00:00 2001 From: Matee ullah Malik Date: Thu, 18 Jun 2026 22:03:34 +0000 Subject: [PATCH 1/5] fix(evmigration): mempool-accept zero-signer migration txs The cosmos/evm ExperimentalEVMMempool routes non-EVM txs through a PriorityNonceMempool that, by default, uses DefaultSignerExtractionAdapter. That adapter calls tx.GetSignaturesV2() and refuses any tx with an empty signature set, returning 'tx must have at least one signer' from PriorityNonceMempool.Insert. MsgClaimLegacyAccount and MsgMigrateValidator are zero-signer by design: authorization lives in the proof bytes, fees are waived by EVMigrationFeeDecorator, and the migration-aware ante chain (app/evm/ante.go: migrationCosmosAnte) accepts the shape. That ante chain, however, runs AFTER mempool insert. Without a migration-aware signer extractor, every submit-proof broadcast is rejected at the mempool layer before ante ever sees it -- including the canonical combined-tx.json shape produced by the offline multisig flow. This change: * Adds app/evmigration_signer_extraction_adapter.go: a SignerExtractionAdapter that returns a synthetic SignerData built from the message's legacy_address for IsEVMigrationOnlyTx, and delegates everything else to a fallback (default for the Cosmos pool, EVM-aware for proposal building). * Wires the adapter into ExperimentalEVMMempool.CosmosPoolConfig and into NewDefaultProposalHandler's signer extraction adapter so it applies on both Insert and PrepareProposal paths. * Replicates upstream's default PriorityNonceMempoolConfig (priority by gas-price) locally so the adapter override is the only behavior change. Short-circuits priority calc for zero-fee/zero-gas txs so it doesn't touch EVM keeper state for migration txs. Tests: * app/evmigration_signer_extraction_adapter_test.go: 7 unit tests pinning synthetic-signer derivation, fallback delegation for non-migration and mixed txs, empty/invalid legacy_address rejection, and nil-fallback safety. * app/evm_mempool_evmigration_test.go: 2 integration tests on the real App + real ExperimentalEVMMempool. One asserts Insert accepts a zero-signer MsgClaimLegacyAccount and CountTx() increments. The other pins the regression: the SDK default adapter still returns zero signers for the same tx, which is precisely what makes PriorityNonceMempool reject without this fix. Docs: * CHANGELOG.md entry under v1.20.0 explaining the fix. * docs/evm-integration/user-guides/migration.md zero-signer-submit callout updated to point at the adapter file. Discovered during v1.20.0-rc4 multisig migration rehearsal (PR #163 migrate-batch.sh end-to-end). Reproduces with migrate-multisig.sh submit, migrate-account.sh, and hand-built lumerad tx broadcast. Verified: go test -tags=test ./app/... green, app package tests pass (14.8s). --- CHANGELOG.md | 1 + app/evm_mempool.go | 77 ++++++++- app/evm_mempool_evmigration_test.go | 96 +++++++++++ app/evmigration_signer_extraction_adapter.go | 105 +++++++++++ ...igration_signer_extraction_adapter_test.go | 163 ++++++++++++++++++ docs/evm-integration/user-guides/migration.md | 2 +- 6 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 app/evm_mempool_evmigration_test.go create mode 100644 app/evmigration_signer_extraction_adapter.go create mode 100644 app/evmigration_signer_extraction_adapter_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e69f5cc..dfe2133e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Full EVM integration documentation: [docs/evm-integration/main.md](docs/evm-inte - Added user-facing migration helper scripts (`scripts/migrate-account.sh`, `scripts/migrate-validator.sh`, `scripts/migrate-multisig.sh`) wrapping the full pre-flight estimate → key import → snapshot → submit → verify flow, with multisig-aware K/N partials, validator-specific cap checks and downtime acknowledgment, and fail-closed query handling so script-level success implies on-chain success. - Added `devnet/scripts/lumera-helper.sh unjail-validator` helper plus downtime warnings in the validator migration guide for operators approaching the slashing window. - Added fee-waiving ante decorator for migration txs (`ante/evmigration_fee_decorator.go`) since new addresses have zero balance pre-migration. +- Added migration-aware mempool signer extractor (`app/evmigration_signer_extraction_adapter.go`) wired into `ExperimentalEVMMempool.CosmosPoolConfig.SignerExtractor`. Without it, the SDK's default `DefaultSignerExtractionAdapter` rejects zero-signer migration txs (`MsgClaimLegacyAccount`, `MsgMigrateValidator`) with "tx must have at least one signer" before the migration-aware ante chain (`migrationCosmosAnte`) ever runs, blocking `submit-proof` broadcast. The adapter synthesizes a deterministic signer from the message's `legacy_address` for migration-only txs and delegates everything else to the EVM-aware default. - Added v1.20.0 upgrade handler with store additions for feemarket, precisebank, vm, erc20, and evmigration; post-migration finalization sets Lumera EVM params, feemarket params, and ERC20 defaults. - Added Action module precompile (`0x0901`) and Supernode module precompile (`0x0902`) giving Solidity contracts native access to `MsgRequestAction`/`MsgFinalizeAction` (including LEP-5 cascade availability commitments) and supernode queries/registration respectively. - Added CosmWasm ↔ EVM cross-runtime bridge (Phase 1, non-payable, depth-1 reentrancy guard): `WasmPrecompile` at `0x0903` exposes `execute`, `query`, `contractInfo`, `rawQuery` to Solidity, and a custom Wasm message handler + query handler decorator (`app/wasm_evm_plugin.go`) lets CosmWasm contracts invoke EVM contracts via `ApplyMessage` with an explicitly-constructed `statedb`. Cross-runtime gas is capped at `DefaultCrossRuntimeGasCap = 3,000,000` per call. diff --git a/app/evm_mempool.go b/app/evm_mempool.go index 08695df7..5b1d78b2 100644 --- a/app/evm_mempool.go +++ b/app/evm_mempool.go @@ -1,7 +1,10 @@ package app import ( + "context" + "cosmossdk.io/log" + sdkmath "cosmossdk.io/math" abci "github.com/cometbft/cometbft/abci/types" "github.com/cosmos/cosmos-sdk/baseapp" @@ -35,6 +38,22 @@ func (app *App) configureEVMMempool(appOpts servertypes.AppOptions, logger log.L app.configureEVMBroadcastOptions(appOpts, broadcastLogger) app.startEVMBroadcastWorker(broadcastLogger) + // Build the Cosmos-side mempool config explicitly so we can install a + // migration-aware SignerExtractionAdapter. Without this override, the + // upstream PriorityNonceMempool falls back to + // DefaultSignerExtractionAdapter, which calls tx.GetSignaturesV2() and + // refuses zero-signer migration txs with "tx must have at least one + // signer" *before* the migration-aware ante chain + // (app/evm/ante.go: migrationCosmosAnte) ever runs. + // + // Priority / Compare / MinValue mirror upstream defaults from + // evmmempool.NewExperimentalEVMMempool (mempool.go ~line 152) so this + // override changes only signer extraction, nothing else. + cosmosPoolConfig := defaultCosmosPoolConfig(app) + cosmosPoolConfig.SignerExtractor = newEVMigrationSignerExtractionAdapter( + sdkmempool.NewDefaultSignerExtractionAdapter(), + ) + // Use cosmos/evm config readers so app.toml/flags values map 1:1 // with upstream EVM behavior. // BroadCastTxFn is overridden to use app.clientCtx at runtime (after @@ -42,6 +61,7 @@ func (app *App) configureEVMMempool(appOpts servertypes.AppOptions, logger log.L mempoolConfig := &evmmempool.EVMMempoolConfig{ AnteHandler: app.AnteHandler(), LegacyPoolConfig: evmconfig.GetLegacyPoolConfig(appOpts, logger), + CosmosPoolConfig: cosmosPoolConfig, BlockGasLimit: evmconfig.GetBlockGasLimit(appOpts, logger), MinTip: evmconfig.GetMinTip(appOpts, logger), BroadCastTxFn: app.broadcastEVMTransactions, @@ -90,11 +110,17 @@ func (app *App) configureEVMMempool(appOpts servertypes.AppOptions, logger log.L }) // PrepareProposal must use EVM-aware signer extraction so Ethereum txs are - // ordered by (sender, nonce) correctly in proposal selection. + // ordered by (sender, nonce) correctly in proposal selection. The + // evmigration-aware adapter is layered underneath so migration-only txs + // — which have zero envelope signers and would otherwise be skipped + // during proposal building — get a synthetic signer derived from + // legacy_address. abciProposalHandler := baseapp.NewDefaultProposalHandler(evmMempool, app) abciProposalHandler.SetSignerExtractionAdapter( evmmempool.NewEthSignerExtractionAdapter( - sdkmempool.NewDefaultSignerExtractionAdapter(), + newEVMigrationSignerExtractionAdapter( + sdkmempool.NewDefaultSignerExtractionAdapter(), + ), ), ) app.SetPrepareProposal(abciProposalHandler.PrepareProposalHandler()) @@ -113,3 +139,50 @@ func (app *App) configureEVMMempool(appOpts servertypes.AppOptions, logger log.L return nil } + +// defaultCosmosPoolConfig replicates the upstream default Cosmos-side mempool +// config that evmmempool.NewExperimentalEVMMempool builds when +// EVMMempoolConfig.CosmosPoolConfig is nil (cosmos/evm mempool.go ~line 152). +// +// We reproduce it here so we can inject our own SignerExtractionAdapter +// (newEVMigrationSignerExtractionAdapter) without changing the priority, +// compare, or min-value semantics. Keep this function aligned with upstream +// when bumping the cosmos/evm dependency. +func defaultCosmosPoolConfig(app *App) *sdkmempool.PriorityNonceMempoolConfig[sdkmath.Int] { + return &sdkmempool.PriorityNonceMempoolConfig[sdkmath.Int]{ + TxPriority: sdkmempool.TxPriority[sdkmath.Int]{ + GetTxPriority: func(goCtx context.Context, tx sdk.Tx) sdkmath.Int { + ctx := sdk.UnwrapSDKContext(goCtx) + cosmosTxFee, ok := tx.(sdk.FeeTx) + if !ok { + return sdkmath.ZeroInt() + } + // Short-circuit zero-fee / zero-gas txs without touching + // EVM keeper state. This matters for two reasons: + // 1. Migration-only txs (MsgClaimLegacyAccount) carry no + // fee — their priority is unambiguously zero and we + // avoid an unnecessary KVStore read. + // 2. The SDK PriorityNonceMempool may invoke this with + // a ctx that has no KVStore attached (e.g. some test + // paths), in which case a state read panics. + fee := cosmosTxFee.GetFee() + gas := cosmosTxFee.GetGas() + if gas == 0 || fee.IsZero() { + return sdkmath.ZeroInt() + } + if app.EVMKeeper == nil { + return sdkmath.ZeroInt() + } + found, coin := fee.Find(app.EVMKeeper.GetEvmCoinInfo(ctx).Denom) + if !found { + return sdkmath.ZeroInt() + } + return coin.Amount.Quo(sdkmath.NewIntFromUint64(gas)) + }, + Compare: func(a, b sdkmath.Int) int { + return a.BigInt().Cmp(b.BigInt()) + }, + MinValue: sdkmath.ZeroInt(), + }, + } +} diff --git a/app/evm_mempool_evmigration_test.go b/app/evm_mempool_evmigration_test.go new file mode 100644 index 00000000..07b9467d --- /dev/null +++ b/app/evm_mempool_evmigration_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" + "github.com/stretchr/testify/require" + + evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +// TestEVMMempool_AcceptsZeroSignerMigrationTx is the regression test for the +// "tx must have at least one signer" rejection on submit-proof that blocked +// the v1.20.0-rc4 multisig migration rehearsal. +// +// Before the fix in app/evmigration_signer_extraction_adapter.go, the +// upstream ExperimentalEVMMempool delegated signer extraction on the Cosmos +// side to DefaultSignerExtractionAdapter, which inspects GetSignaturesV2() +// and refuses any tx with zero envelope signatures — exactly the shape that +// MsgClaimLegacyAccount produces by design. That refusal happened at the +// mempool layer, *before* the migration-aware ante chain +// (app/evm/ante.go: migrationCosmosAnte) could admit the tx. +// +// With the fix, a migration-only tx still has zero envelope sigs but the +// mempool synthesizes a signer from legacy_address, the Insert succeeds, and +// the ante chain runs as designed. We assert: no error from Insert AND the +// mempool count increased. +func TestEVMMempool_AcceptsZeroSignerMigrationTx(t *testing.T) { + app := Setup(t) + require.NotNil(t, app.GetMempool(), "mempool must be wired") + + txConfig := app.TxConfig() + txBuilder := txConfig.NewTxBuilder() + + // Build the simplest possible migration message. The mempool path we + // are exercising never inspects proof bytes — that's the ante's job — + // so legacy_address is the only field we must populate. + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", + LegacyAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", + } + require.NoError(t, txBuilder.SetMsgs(msg)) + // Zero fee / zero gas — migration ante waives fees. Critically: no + // signatures set on the builder; the resulting tx has signer_infos: [] + // and signatures: [], matching exactly what `lumerad tx evmigration + // submit-proof` produces on a real chain. + txBuilder.SetGasLimit(0) + + tx := txBuilder.GetTx() + + ctx := sdk.Context{}.WithBlockHeight(1) + + before := app.GetMempool().CountTx() + err := app.GetMempool().Insert(ctx, tx) + require.NoError(t, err, "zero-signer migration tx must be accepted by the mempool") + require.NotContains(t, + "", // dummy — using NotContains on err.Error() above wouldn't fire when err is nil + "tx must have at least one signer", + "sanity assertion (kept for grep when this regresses)", + ) + require.Equal(t, before+1, app.GetMempool().CountTx(), "mempool count must increment for the accepted tx") +} + +// TestEVMigrationSignerAdapter_FallbackRejectsZeroSignerTx pins the failure +// mode in absence of the adapter. This is the corner of the failure surface +// that the v1.20.0-rc4 multisig migration rehearsal walked into: build the +// SAME zero-signer migration tx and feed it directly to the SDK default +// signer extractor, then assert it reports the "tx must have at least one +// signer" symptom. If this test EVER starts passing (i.e. the default +// adapter learns to handle this shape upstream), the workaround in +// app/evm_mempool.go can be reconsidered. +func TestEVMigrationSignerAdapter_FallbackRejectsZeroSignerTx(t *testing.T) { + app := Setup(t) + txConfig := app.TxConfig() + txBuilder := txConfig.NewTxBuilder() + + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", + LegacyAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", + } + require.NoError(t, txBuilder.SetMsgs(msg)) + tx := txBuilder.GetTx() + + defaultAdapter := sdkmempool.NewDefaultSignerExtractionAdapter() + sigs, err := defaultAdapter.GetSigners(tx) + require.NoError(t, err, "default adapter returns no error on zero-sig tx — it just returns an empty slice") + require.Empty(t, sigs, "default adapter yields zero signers for migration tx — this is what makes PriorityNonceMempool.Insert reject with 'tx must have at least one signer'") + + // The migration-aware adapter, by contrast, yields exactly one + // synthetic signer for the same tx. + migAdapter := newEVMigrationSignerExtractionAdapter(defaultAdapter) + migSigs, err := migAdapter.GetSigners(tx) + require.NoError(t, err) + require.Len(t, migSigs, 1, "migration-aware adapter must synthesize exactly one signer") +} diff --git a/app/evmigration_signer_extraction_adapter.go b/app/evmigration_signer_extraction_adapter.go new file mode 100644 index 00000000..96feab0d --- /dev/null +++ b/app/evmigration_signer_extraction_adapter.go @@ -0,0 +1,105 @@ +package app + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" + + lumante "github.com/LumeraProtocol/lumera/ante" + evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +// evmigrationSignerExtractionAdapter is a SignerExtractionAdapter that +// understands EVM-migration transactions. +// +// Migration messages (MsgClaimLegacyAccount, MsgMigrateValidator) are +// authenticated by the proof bytes embedded in the message — they declare +// zero envelope signers. The Cosmos SDK mempool's default +// DefaultSignerExtractionAdapter calls tx.GetSignaturesV2() and refuses any +// tx whose signature set is empty (priority_nonce.go: "tx must have at +// least one signer"). That refusal happens before the ante chain runs, +// which means the migration-aware ante decorators +// (EVMigrationValidateBasicDecorator, evmigrationProofVerificationDecorator +// in app/evm/ante.go) never get a chance to admit the tx. +// +// For migration-only txs we synthesize a SignerData from the message's +// legacy_address: that string is a deterministic, on-chain canonical bytes +// representation of the source account, which is what the nonce mempool +// needs for (sender, nonce) ordering and dedupe — exactly the role normally +// served by the envelope signer. Sequence is held at 0 because migration is +// a one-shot, replay-prevented-by-keeper operation; the nonce mempool's +// dedup-by-sender path will still reject a duplicate insert in the same +// block window, which is the correct mempool semantics. +// +// All non-migration txs fall through to the supplied fallback unchanged +// (typically the SDK default adapter or, when wrapped by the EVM proposal +// handler, the EVM-aware adapter). +type evmigrationSignerExtractionAdapter struct { + fallback sdkmempool.SignerExtractionAdapter +} + +var _ sdkmempool.SignerExtractionAdapter = evmigrationSignerExtractionAdapter{} + +// newEVMigrationSignerExtractionAdapter constructs an adapter that returns a +// synthetic signer for migration-only txs and delegates everything else to +// fallback. +func newEVMigrationSignerExtractionAdapter(fallback sdkmempool.SignerExtractionAdapter) evmigrationSignerExtractionAdapter { + if fallback == nil { + fallback = sdkmempool.NewDefaultSignerExtractionAdapter() + } + return evmigrationSignerExtractionAdapter{fallback: fallback} +} + +// GetSigners implements sdkmempool.SignerExtractionAdapter. +func (s evmigrationSignerExtractionAdapter) GetSigners(tx sdk.Tx) ([]sdkmempool.SignerData, error) { + if !lumante.IsEVMigrationOnlyTx(tx) { + return s.fallback.GetSigners(tx) + } + + msgs := tx.GetMsgs() + if len(msgs) == 0 { + // Defensive: IsEVMigrationOnlyTx already returns false for empty + // msg sets, but keep the invariant local. + return s.fallback.GetSigners(tx) + } + + // All messages in a migration-only tx are evmigration messages with a + // legacy_address. We anchor the synthetic signer to the FIRST message's + // legacy_address; a well-formed migration tx is single-message (see + // EVMigrationValidateBasicDecorator) and submit-proof never bundles + // multiple migration messages today, so this is safe. If batching is + // ever introduced, each batch member must share the same legacy_address + // for the ordering to remain coherent, and a ValidateBasic check should + // enforce that. + legacyAddr, err := legacyAddressOfMigrationMsg(msgs[0]) + if err != nil { + return nil, err + } + if legacyAddr == "" { + return nil, fmt.Errorf("evmigration tx has empty legacy_address; cannot derive mempool signer") + } + + acc, err := sdk.AccAddressFromBech32(legacyAddr) + if err != nil { + return nil, fmt.Errorf("evmigration tx legacy_address %q is not a valid bech32: %w", legacyAddr, err) + } + + return []sdkmempool.SignerData{ + sdkmempool.NewSignerData(acc, 0), + }, nil +} + +// legacyAddressOfMigrationMsg extracts the legacy_address from a recognized +// migration message. Returns ("", nil) only for unrecognized message types, +// which IsEVMigrationOnlyTx should have already rejected upstream. +func legacyAddressOfMigrationMsg(msg sdk.Msg) (string, error) { + switch m := msg.(type) { + case *evmigrationtypes.MsgClaimLegacyAccount: + return m.LegacyAddress, nil + case *evmigrationtypes.MsgMigrateValidator: + return m.LegacyAddress, nil + default: + return "", fmt.Errorf("evmigration signer adapter: unexpected message type %T in migration-only tx", msg) + } +} diff --git a/app/evmigration_signer_extraction_adapter_test.go b/app/evmigration_signer_extraction_adapter_test.go new file mode 100644 index 00000000..94273f56 --- /dev/null +++ b/app/evmigration_signer_extraction_adapter_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "errors" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +// stubMsgsTx is a minimal sdk.Tx that just carries a message slice — enough +// for the signer-extraction adapter to inspect. +type stubMsgsTx struct { + msgs []sdk.Msg +} + +func (m stubMsgsTx) GetMsgs() []sdk.Msg { return m.msgs } +func (m stubMsgsTx) GetMsgsV2() ([]proto.Message, error) { return nil, nil } +func (m stubMsgsTx) ValidateBasic() error { return nil } + +// recordingFallback lets us assert that the adapter delegates correctly +// for non-migration txs and does NOT delegate for migration-only txs. +type recordingFallback struct { + called int + returnErr error + returnSig []sdkmempool.SignerData +} + +func (r *recordingFallback) GetSigners(_ sdk.Tx) ([]sdkmempool.SignerData, error) { + r.called++ + return r.returnSig, r.returnErr +} + +// A well-formed Lumera bech32 from a known foundation legacy address shape. +// The exact value does not matter — only that it parses as a bech32 and +// round-trips through AccAddressFromBech32. +const testLegacyBech32 = "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw" + +func TestEVMigrationSignerExtractionAdapter_MigrationOnlyTx_SyntheticSigner(t *testing.T) { + fb := &recordingFallback{} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1newaddressxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + LegacyAddress: testLegacyBech32, + }, + }, + } + + sigs, err := adapter.GetSigners(tx) + require.NoError(t, err) + require.Len(t, sigs, 1, "migration-only tx must yield exactly one synthetic signer") + expectedAcc, err := sdk.AccAddressFromBech32(testLegacyBech32) + require.NoError(t, err) + require.Equal(t, expectedAcc, sigs[0].Signer, "synthetic signer must equal AccAddress(legacy_address)") + require.Equal(t, uint64(0), sigs[0].Sequence, "migration tx sequence must be 0") + require.Zero(t, fb.called, "fallback must NOT be called for migration-only txs") +} + +func TestEVMigrationSignerExtractionAdapter_MigrationOnlyTx_MigrateValidator(t *testing.T) { + fb := &recordingFallback{} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgMigrateValidator{ + NewAddress: "lumeravaloper1newvaloperxxxxxxxxxxxxxxxxxxxxxxxxx", + LegacyAddress: testLegacyBech32, + }, + }, + } + + sigs, err := adapter.GetSigners(tx) + require.NoError(t, err) + require.Len(t, sigs, 1) + require.Equal(t, uint64(0), sigs[0].Sequence) + require.Zero(t, fb.called) +} + +func TestEVMigrationSignerExtractionAdapter_NonMigrationTx_DelegatesToFallback(t *testing.T) { + expected := []sdkmempool.SignerData{ + sdkmempool.NewSignerData(sdk.AccAddress("dummy-signer-bytes"), 42), + } + fb := &recordingFallback{returnSig: expected} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{msgs: []sdk.Msg{&banktypes.MsgSend{}}} + + sigs, err := adapter.GetSigners(tx) + require.NoError(t, err) + require.Equal(t, 1, fb.called, "non-migration tx must delegate to fallback") + require.Equal(t, expected, sigs, "fallback result must be returned verbatim") +} + +func TestEVMigrationSignerExtractionAdapter_MixedTx_DelegatesToFallback(t *testing.T) { + // Mixed tx (migration + non-migration message) is rejected by + // IsEVMigrationOnlyTx, so the adapter must delegate. The mempool will + // then see the real envelope signers — which the operator must have + // provided — and rejection at the ante chain happens through the normal + // fee/sig decorators rather than this adapter. + fb := &recordingFallback{returnErr: errors.New("fallback ran")} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgClaimLegacyAccount{LegacyAddress: testLegacyBech32}, + &banktypes.MsgSend{}, + }, + } + + _, err := adapter.GetSigners(tx) + require.Error(t, err) + require.EqualError(t, err, "fallback ran") + require.Equal(t, 1, fb.called) +} + +func TestEVMigrationSignerExtractionAdapter_EmptyLegacyAddress_Rejected(t *testing.T) { + fb := &recordingFallback{} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgClaimLegacyAccount{LegacyAddress: ""}, + }, + } + + _, err := adapter.GetSigners(tx) + require.Error(t, err, "empty legacy_address must produce a clear adapter error") + require.Contains(t, err.Error(), "empty legacy_address") + require.Zero(t, fb.called) +} + +func TestEVMigrationSignerExtractionAdapter_InvalidBech32_Rejected(t *testing.T) { + fb := &recordingFallback{} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgClaimLegacyAccount{LegacyAddress: "not-a-bech32"}, + }, + } + + _, err := adapter.GetSigners(tx) + require.Error(t, err) + require.Contains(t, err.Error(), "not a valid bech32") +} + +func TestEVMigrationSignerExtractionAdapter_NilFallback_FallsBackToDefault(t *testing.T) { + // Sanity check: passing nil fallback must NOT panic; a default adapter + // is substituted. A non-migration tx using the default adapter against + // a tx that doesn't implement SigVerifiableTx returns an error, which + // is fine here — we just want to prove no nil-deref. + adapter := newEVMigrationSignerExtractionAdapter(nil) + tx := stubMsgsTx{msgs: []sdk.Msg{&banktypes.MsgSend{}}} + _, _ = adapter.GetSigners(tx) // must not panic +} diff --git a/docs/evm-integration/user-guides/migration.md b/docs/evm-integration/user-guides/migration.md index 27d6608d..8f7cc035 100644 --- a/docs/evm-integration/user-guides/migration.md +++ b/docs/evm-integration/user-guides/migration.md @@ -598,7 +598,7 @@ Multisig legacy accounts (flat K-of-N `secp256k1`) use an offline, coordinator-d > - **Shape + K/N must mirror.** A K-of-N legacy multisig migrates to a K-of-N`eth_secp256k1` multisig — same K, same N. Different K, different N, or single↔multisig shape mismatch is rejected with`ErrMirrorSourceMismatch` (code 1121). > - **Same K signer positions sign both halves.**`legacy_proof.signer_indices` must equal`new_proof.signer_indices`. Co-signers who sign only one side don't count toward the K-of-K threshold on the other. > - **Sub-key uniqueness.** Each side's`sub_pub_keys` must have pairwise-distinct entries. -> - **Zero-signer submit.**`submit-proof` takes no`--from`, no fee flags, no envelope signature — authorization is the proof bytes. +> - **Zero-signer submit.**`submit-proof` takes no`--from`, no fee flags, no envelope signature — authorization is the proof bytes. Mempool acceptance of zero-signer migration txs requires `app/evmigration_signer_extraction_adapter.go` to be wired into the EVM mempool's `CosmosPoolConfig.SignerExtractor`; without it, `ExperimentalEVMMempool` falls back to the SDK's default extractor and rejects the tx with `tx must have at least one signer` before the migration-aware ante chain runs. > > Full reference with error codes and helper functions: [legacy-migration.md § Consensus invariants](../evmigration/legacy-migration.md#consensus-invariants). From 236b7aa5907d7eaad1de3ead85bfd27372657255 Mon Sep 17 00:00:00 2001 From: Matee ullah Malik Date: Thu, 18 Jun 2026 23:53:27 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(evmigration):=20strengthen=20mempool?= =?UTF-8?q?=20tests=20=E2=80=94=20full=20CheckTx=20path=20+=20security=20p?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Kay's review feedback on PR #167 ("need more tests for all that"). Three new tests replace the prior thin mempool.Insert direct call: 1. TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx Drives a valid zero-signer migration tx through the SAME app.CheckTx entry point that 'lumerad tx evmigration submit-proof' hits on live mainnet. Asserts response code 0 and that the log NEVER contains 'at least one signer'. This is the production regression pin. 2. TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx Security pin for the worry that the SignerExtractionAdapter widens the hole: submits a zero-signer banktypes.MsgSend through the same CheckTx entry point and asserts it is REJECTED. Proves the adapter only synthesizes signers for migration-only txs and that all other message types still require envelope signatures. 3. TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode Documents the upstream SDK behavior that necessitates the custom adapter — default extractor returns empty []SignerData on a zero-signer migration tx. If this ever changes upstream, we can remove the workaround. Regression-pin verified locally by temporarily reverting app/evm_mempool.go to master: test #1 fails with the exact production error, test #2 still passes (confirming no widening), then restored. --- app/evm_mempool_evmigration_test.go | 232 +++++++++++++++++++--------- 1 file changed, 163 insertions(+), 69 deletions(-) diff --git a/app/evm_mempool_evmigration_test.go b/app/evm_mempool_evmigration_test.go index 07b9467d..7afc1262 100644 --- a/app/evm_mempool_evmigration_test.go +++ b/app/evm_mempool_evmigration_test.go @@ -1,96 +1,190 @@ -package app +package app_test import ( + "crypto/sha256" + "fmt" "testing" + abci "github.com/cometbft/cometbft/abci/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" sdk "github.com/cosmos/cosmos-sdk/types" sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + evmcryptotypes "github.com/cosmos/evm/crypto/ethsecp256k1" "github.com/stretchr/testify/require" + lumeraapp "github.com/LumeraProtocol/lumera/app" + lcfg "github.com/LumeraProtocol/lumera/config" evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" ) -// TestEVMMempool_AcceptsZeroSignerMigrationTx is the regression test for the -// "tx must have at least one signer" rejection on submit-proof that blocked -// the v1.20.0-rc4 multisig migration rehearsal. +// testChainID matches the chain-id used by Setup(t) (see app/test_helpers.go). +const testChainID = "testing" + +// TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx is the end-to-end +// regression test for the production bug behind PR #167. // -// Before the fix in app/evmigration_signer_extraction_adapter.go, the -// upstream ExperimentalEVMMempool delegated signer extraction on the Cosmos -// side to DefaultSignerExtractionAdapter, which inspects GetSignaturesV2() -// and refuses any tx with zero envelope signatures — exactly the shape that -// MsgClaimLegacyAccount produces by design. That refusal happened at the -// mempool layer, *before* the migration-aware ante chain -// (app/evm/ante.go: migrationCosmosAnte) could admit the tx. +// Real flow on a v1.20.0 mainnet binary: an operator runs // -// With the fix, a migration-only tx still has zero envelope sigs but the -// mempool synthesizes a signer from legacy_address, the Insert succeeds, and -// the ante chain runs as designed. We assert: no error from Insert AND the -// mempool count increased. -func TestEVMMempool_AcceptsZeroSignerMigrationTx(t *testing.T) { - app := Setup(t) - require.NotNil(t, app.GetMempool(), "mempool must be wired") - - txConfig := app.TxConfig() - txBuilder := txConfig.NewTxBuilder() - - // Build the simplest possible migration message. The mempool path we - // are exercising never inspects proof bytes — that's the ante's job — - // so legacy_address is the only field we must populate. - msg := &evmigrationtypes.MsgClaimLegacyAccount{ - NewAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", - LegacyAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", - } - require.NoError(t, txBuilder.SetMsgs(msg)) - // Zero fee / zero gas — migration ante waives fees. Critically: no - // signatures set on the builder; the resulting tx has signer_infos: [] - // and signatures: [], matching exactly what `lumerad tx evmigration - // submit-proof` produces on a real chain. - txBuilder.SetGasLimit(0) +// lumerad tx evmigration submit-proof tx.json +// +// which posts the encoded tx bytes to BaseApp.CheckTx. CheckTx (with the +// experimental EVM mempool wired) runs: +// +// 1. ante chain (migrationCosmosAnte for migration-only txs — accepts +// zero-signer txs by design) +// 2. app.mempool.Insert(ctx, tx) — which delegates signer extraction to +// the configured SignerExtractionAdapter. +// +// Before the fix, step (2) used DefaultSignerExtractionAdapter which returns +// an empty []SignerData for a zero-signer migration tx, causing +// PriorityNonceMempool.Insert to reject with +// "tx must have at least one signer". This test goes through the EXACT same +// CheckTx entry point an operator hits and asserts the response is non-zero. +// +// This is a stronger test than calling app.GetMempool().Insert(...) directly +// because it exercises the proposer-pool wiring as well, and because it +// drives the same code path the live binary uses on broadcast. +func TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) - tx := txBuilder.GetTx() + msg := validMigrationMsgForMempool(t, testChainID) + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + txBytes, err := app.TxConfig().TxEncoder()(tx) + require.NoError(t, err) - ctx := sdk.Context{}.WithBlockHeight(1) - - before := app.GetMempool().CountTx() - err := app.GetMempool().Insert(ctx, tx) - require.NoError(t, err, "zero-signer migration tx must be accepted by the mempool") - require.NotContains(t, - "", // dummy — using NotContains on err.Error() above wouldn't fire when err is nil - "tx must have at least one signer", - "sanity assertion (kept for grep when this regresses)", - ) - require.Equal(t, before+1, app.GetMempool().CountTx(), "mempool count must increment for the accepted tx") + resp, err := app.CheckTx(&abci.RequestCheckTx{ + Tx: txBytes, + Type: abci.CheckTxType_New, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + // Hard assertion against the symptom: the CheckTx log must NEVER contain + // the mempool's "at least one signer" rejection. If this string appears, + // the EVM mempool fix has regressed. + require.NotContains(t, resp.Log, "at least one signer", + "CheckTx must not surface the mempool's zero-signer rejection on a valid migration tx") + + // Acceptance: the migration tx must reach CheckTx success (code 0). + // If the ante or mempool rejects for any other reason, fail loudly so the + // failure mode is visible — this test is the canary for the full CheckTx + // path on submit-proof. + require.Zero(t, resp.Code, + "CheckTx must accept a valid zero-signer migration tx (code=0); got code=%d log=%q", + resp.Code, resp.Log) } -// TestEVMigrationSignerAdapter_FallbackRejectsZeroSignerTx pins the failure -// mode in absence of the adapter. This is the corner of the failure surface -// that the v1.20.0-rc4 multisig migration rehearsal walked into: build the -// SAME zero-signer migration tx and feed it directly to the SDK default -// signer extractor, then assert it reports the "tx must have at least one -// signer" symptom. If this test EVER starts passing (i.e. the default -// adapter learns to handle this shape upstream), the workaround in -// app/evm_mempool.go can be reconsidered. -func TestEVMigrationSignerAdapter_FallbackRejectsZeroSignerTx(t *testing.T) { - app := Setup(t) - txConfig := app.TxConfig() - txBuilder := txConfig.NewTxBuilder() - - msg := &evmigrationtypes.MsgClaimLegacyAccount{ - NewAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", - LegacyAddress: "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw", - } +// TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx is the security pin +// Andrey asked for: the SignerExtractionAdapter fix MUST NOT loosen mempool +// checks for any tx that isn't a payload-authenticated migration message. +// A zero-signer banktypes.MsgSend submitted through the same CheckTx entry +// point must still be rejected. +// +// If this test ever turns green, the adapter has widened the hole — every +// non-migration message type would then be able to bypass mempool signer +// extraction, which is exactly the security regression we promised would +// not happen. +func TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + from := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + to := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + msg := banktypes.NewMsgSend(from, to, sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 1))) + + txBuilder := app.TxConfig().NewTxBuilder() require.NoError(t, txBuilder.SetMsgs(msg)) + txBuilder.SetGasLimit(200_000) + // Deliberately NO signatures set: this mirrors a malicious or buggy + // operator submitting a zero-signer tx for a non-migration message. tx := txBuilder.GetTx() + txBytes, err := app.TxConfig().TxEncoder()(tx) + require.NoError(t, err) + + resp, err := app.CheckTx(&abci.RequestCheckTx{ + Tx: txBytes, + Type: abci.CheckTxType_New, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotZero(t, resp.Code, + "zero-signer NON-migration tx must be rejected by CheckTx; got code=0 (security regression — adapter widened the hole)") +} + +// TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode pins the +// SDK-side behavior that necessitates the custom adapter. The default +// extractor returns an empty signer slice for a zero-signer migration tx, +// which is what PriorityNonceMempool.Insert turns into the +// "tx must have at least one signer" error. The migration-aware adapter +// returns exactly one synthetic signer for the same tx. If the default +// extractor ever learns to handle this shape upstream, the workaround can +// be reconsidered. +func TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := validMigrationMsgForMempool(t, testChainID) + tx := newUnsignedMigrationTxForMempool(t, app, msg) + defaultAdapter := sdkmempool.NewDefaultSignerExtractionAdapter() sigs, err := defaultAdapter.GetSigners(tx) require.NoError(t, err, "default adapter returns no error on zero-sig tx — it just returns an empty slice") require.Empty(t, sigs, "default adapter yields zero signers for migration tx — this is what makes PriorityNonceMempool.Insert reject with 'tx must have at least one signer'") +} + +// validMigrationMsgForMempool builds a MsgClaimLegacyAccount whose embedded +// proofs pass ante-level cryptographic verification, so the only thing that +// can reject the tx in CheckTx is the mempool's signer-extraction step. +// +// This mirrors validMigrationMsg in app/evm/ante_evmigration_fee_test.go but +// lives here to avoid a cross-package test-only export. +func validMigrationMsgForMempool(t *testing.T, chainID string) *evmigrationtypes.MsgClaimLegacyAccount { + t.Helper() + + legacyPriv := secp256k1.GenPrivKey() + newPriv, err := evmcryptotypes.GenerateKey() + require.NoError(t, err) + + legacy := sdk.AccAddress(legacyPriv.PubKey().Address().Bytes()) + newAddr := sdk.AccAddress(newPriv.PubKey().Address().Bytes()) + require.False(t, legacy.Equals(newAddr)) - // The migration-aware adapter, by contrast, yields exactly one - // synthetic signer for the same tx. - migAdapter := newEVMigrationSignerExtractionAdapter(defaultAdapter) - migSigs, err := migAdapter.GetSigners(tx) + payload := []byte(fmt.Sprintf( + "lumera-evm-migration:%s:%d:claim:%s:%s", + chainID, + lcfg.EVMChainID, + legacy.String(), + newAddr.String(), + )) + legacyHash := sha256.Sum256(payload) + legacySig, err := legacyPriv.Sign(legacyHash[:]) require.NoError(t, err) - require.Len(t, migSigs, 1, "migration-aware adapter must synthesize exactly one signer") + + newSig, err := newPriv.Sign(payload) + require.NoError(t, err) + + return &evmigrationtypes.MsgClaimLegacyAccount{ + LegacyAddress: legacy.String(), + NewAddress: newAddr.String(), + LegacyProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: legacyPriv.PubKey().Bytes(), + Signature: legacySig, + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + NewProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: newPriv.PubKey().Bytes(), + Signature: newSig, + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + } +} + +func newUnsignedMigrationTxForMempool(t *testing.T, app *lumeraapp.App, msgs ...sdk.Msg) sdk.Tx { + t.Helper() + + txBuilder := app.TxConfig().NewTxBuilder() + require.NoError(t, txBuilder.SetMsgs(msgs...)) + txBuilder.SetGasLimit(200_000) + return txBuilder.GetTx() } From f2cd61ebeedf3a7308950cb7f93f9f541b61aa2c Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 18 Jun 2026 21:18:48 -0400 Subject: [PATCH 3/5] fix(evmigration): gate zero-fee migration txs to the admission window + harden mempool tests Admitting zero-signer migration txs to the app mempool (the signer-extraction adapter) also opened a zero-fee spam vector: migration txs carry no fee and no envelope signature, so anyone could flood the mempool/proposals with proof-valid txs that only fail at message execution. Enforce the migration admission window at the ante so these are rejected before mempool insertion. - x/evmigration/keeper/ante.go: VerifyMigrationProofsForAnte now rejects with ErrMigrationDisabled / ErrMigrationWindowClosed when EnableMigration is off or MigrationEndTime has passed (mirrors preChecks steps 1-2). Single param read, no per-account state; no-op under default params (enabled, no deadline). On mainnet a concrete MigrationEndTime bounds the exposure to the migration window and closes it automatically. Message execution still re-checks. - ante_test.go: TestVerifyMigrationProofsForAnte_AdmissionGate (disabled / window-closed / open-window). Review hardening of the mempool test suite: - Renamed TestEVMigrationMalformedLegacyAddressRejected* -> ...ByValidateBasic and documented that it pins the ante ValidateBasic layer, not the adapter. - Made CheckTxRejectsZeroSignerNonMigrationTx assert the rejecting layer ("no signatures supplied") instead of just code != 0, and added InsertRejectsZeroSignerNonMigrationTx as the true adapter-layer pin (drives mempool.Insert directly, bypassing the ante). - Documented the gas==0 div-by-zero hardening in defaultCosmosPoolConfig. - Track the previously-untracked real-node integration test so the branch matches the docs/CHANGELOG references. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- app/evm_mempool.go | 11 +- app/evm_mempool_evmigration_test.go | 173 ++++++++++++++++-- app/evm_mempool_test.go | 16 ++ app/evmigration_signer_extraction_adapter.go | 20 +- ...igration_signer_extraction_adapter_test.go | 17 ++ app/test_helpers.go | 14 ++ docs/evm-integration/architecture/rollout.md | 4 +- docs/evm-integration/testing/bugs.md | 14 ++ docs/evm-integration/testing/tests.md | 20 +- .../testing/tests/integration-evmigration.md | 6 + .../testing/tests/integration-mempool.md | 4 + .../testing/tests/unit-app-wiring.md | 1 + .../testing/tests/unit-evmigration.md | 27 +++ docs/evm-integration/user-guides/migration.md | 2 +- .../mempool/evmigration_zero_signer_test.go | 173 ++++++++++++++++++ x/evmigration/keeper/ante.go | 29 ++- x/evmigration/keeper/ante_test.go | 48 +++++ 18 files changed, 539 insertions(+), 44 deletions(-) create mode 100644 tests/integration/evm/mempool/evmigration_zero_signer_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe2133e..253871b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,9 @@ Full EVM integration documentation: [docs/evm-integration/main.md](docs/evm-inte - Added user-facing migration helper scripts (`scripts/migrate-account.sh`, `scripts/migrate-validator.sh`, `scripts/migrate-multisig.sh`) wrapping the full pre-flight estimate → key import → snapshot → submit → verify flow, with multisig-aware K/N partials, validator-specific cap checks and downtime acknowledgment, and fail-closed query handling so script-level success implies on-chain success. - Added `devnet/scripts/lumera-helper.sh unjail-validator` helper plus downtime warnings in the validator migration guide for operators approaching the slashing window. - Added fee-waiving ante decorator for migration txs (`ante/evmigration_fee_decorator.go`) since new addresses have zero balance pre-migration. -- Added migration-aware mempool signer extractor (`app/evmigration_signer_extraction_adapter.go`) wired into `ExperimentalEVMMempool.CosmosPoolConfig.SignerExtractor`. Without it, the SDK's default `DefaultSignerExtractionAdapter` rejects zero-signer migration txs (`MsgClaimLegacyAccount`, `MsgMigrateValidator`) with "tx must have at least one signer" before the migration-aware ante chain (`migrationCosmosAnte`) ever runs, blocking `submit-proof` broadcast. The adapter synthesizes a deterministic signer from the message's `legacy_address` for migration-only txs and delegates everything else to the EVM-aware default. +- Added migration-aware mempool signer extractor (`app/evmigration_signer_extraction_adapter.go`) wired into `ExperimentalEVMMempool.CosmosPoolConfig.SignerExtractor`. Without it, the SDK's default `DefaultSignerExtractionAdapter` rejects zero-signer migration txs (`MsgClaimLegacyAccount`, `MsgMigrateValidator`) with "tx must have at least one signer" during app-side mempool admission/proposal selection, blocking `submit-proof` broadcast. The adapter synthesizes a deterministic signer from the message's `legacy_address` for migration-only txs and delegates everything else to the EVM-aware default. +- Added regression coverage for zero-signer evmigration tx admission: unit tests pin the upstream SDK mempool rejection, adapter fallback and negative cases (malformed `legacy_address`, multi-message and mixed-message txs), app tests cover `PrepareProposal` inclusion and disabled-mempool wiring, and real-node integration tests broadcast `submit-proof`-style tx bytes through CometBFT `broadcast_tx_sync`. +- Hardened the migration ante (`x/evmigration/keeper/ante.go`) to enforce the migration admission window before mempool admission: since migration txs are fee-free and signature-free, `VerifyMigrationProofsForAnte` now rejects them with `ErrMigrationDisabled`/`ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed. This bounds the zero-fee mempool-spam surface to the operator-defined window (no-op under default params; mainnet sets a concrete `MigrationEndTime`). - Added v1.20.0 upgrade handler with store additions for feemarket, precisebank, vm, erc20, and evmigration; post-migration finalization sets Lumera EVM params, feemarket params, and ERC20 defaults. - Added Action module precompile (`0x0901`) and Supernode module precompile (`0x0902`) giving Solidity contracts native access to `MsgRequestAction`/`MsgFinalizeAction` (including LEP-5 cascade availability commitments) and supernode queries/registration respectively. - Added CosmWasm ↔ EVM cross-runtime bridge (Phase 1, non-payable, depth-1 reentrancy guard): `WasmPrecompile` at `0x0903` exposes `execute`, `query`, `contractInfo`, `rawQuery` to Solidity, and a custom Wasm message handler + query handler decorator (`app/wasm_evm_plugin.go`) lets CosmWasm contracts invoke EVM contracts via `ApplyMessage` with an explicitly-constructed `statedb`. Cross-runtime gas is capped at `DefaultCrossRuntimeGasCap = 3,000,000` per call. diff --git a/app/evm_mempool.go b/app/evm_mempool.go index 5b1d78b2..89a64217 100644 --- a/app/evm_mempool.go +++ b/app/evm_mempool.go @@ -43,8 +43,7 @@ func (app *App) configureEVMMempool(appOpts servertypes.AppOptions, logger log.L // upstream PriorityNonceMempool falls back to // DefaultSignerExtractionAdapter, which calls tx.GetSignaturesV2() and // refuses zero-signer migration txs with "tx must have at least one - // signer" *before* the migration-aware ante chain - // (app/evm/ante.go: migrationCosmosAnte) ever runs. + // signer" during mempool admission and proposal selection. // // Priority / Compare / MinValue mirror upstream defaults from // evmmempool.NewExperimentalEVMMempool (mempool.go ~line 152) so this @@ -158,13 +157,19 @@ func defaultCosmosPoolConfig(app *App) *sdkmempool.PriorityNonceMempoolConfig[sd return sdkmath.ZeroInt() } // Short-circuit zero-fee / zero-gas txs without touching - // EVM keeper state. This matters for two reasons: + // EVM keeper state. This matters for three reasons: // 1. Migration-only txs (MsgClaimLegacyAccount) carry no // fee — their priority is unambiguously zero and we // avoid an unnecessary KVStore read. // 2. The SDK PriorityNonceMempool may invoke this with // a ctx that has no KVStore attached (e.g. some test // paths), in which case a state read panics. + // 3. The gas == 0 guard also hardens against a + // division-by-zero panic in the final + // coin.Amount.Quo(gas): upstream's default priority + // function (cosmos/evm mempool.go ~line 152) divides by + // GetGas() with no zero guard, so this is strictly safer + // than the code it replicates. fee := cosmosTxFee.GetFee() gas := cosmosTxFee.GetGas() if gas == 0 || fee.IsZero() { diff --git a/app/evm_mempool_evmigration_test.go b/app/evm_mempool_evmigration_test.go index 7afc1262..450b696a 100644 --- a/app/evm_mempool_evmigration_test.go +++ b/app/evm_mempool_evmigration_test.go @@ -21,6 +21,8 @@ import ( // testChainID matches the chain-id used by Setup(t) (see app/test_helpers.go). const testChainID = "testing" +const testLegacyBech32 = "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw" + // TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx is the end-to-end // regression test for the production bug behind PR #167. // @@ -40,11 +42,10 @@ const testChainID = "testing" // an empty []SignerData for a zero-signer migration tx, causing // PriorityNonceMempool.Insert to reject with // "tx must have at least one signer". This test goes through the EXACT same -// CheckTx entry point an operator hits and asserts the response is non-zero. +// CheckTx entry point an operator hits and asserts the response succeeds. // // This is a stronger test than calling app.GetMempool().Insert(...) directly -// because it exercises the proposer-pool wiring as well, and because it -// drives the same code path the live binary uses on broadcast. +// because it drives the same code path the live binary uses on broadcast. func TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx(t *testing.T) { app := lumeraapp.Setup(t) @@ -76,16 +77,23 @@ func TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx(t *testing.T) { resp.Code, resp.Log) } -// TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx is the security pin -// Andrey asked for: the SignerExtractionAdapter fix MUST NOT loosen mempool -// checks for any tx that isn't a payload-authenticated migration message. -// A zero-signer banktypes.MsgSend submitted through the same CheckTx entry -// point must still be rejected. +// TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx is the end-to-end +// defense-in-depth pin: a zero-signer banktypes.MsgSend submitted through the +// same CheckTx entry point an operator hits must still be rejected. +// +// LAYERING NOTE: this rejection comes from the SDK signature-verification +// decorator in the ante chain ("no signatures supplied", codespace "sdk", +// code ErrNoSignatures=15), which runs BEFORE mempool admission. It therefore +// does NOT exercise the signer-extraction adapter at all — the ante stops the +// tx first. This test only proves the live path still rejects a malicious +// zero-signer non-migration tx; it cannot, on its own, detect an adapter that +// widened the hole, because the ante would mask such a regression here. // -// If this test ever turns green, the adapter has widened the hole — every -// non-migration message type would then be able to bypass mempool signer -// extraction, which is exactly the security regression we promised would -// not happen. +// The adapter-layer security guarantee — that a non-migration tx gets NO +// synthetic signer and is rejected at mempool admission — is pinned directly, +// bypassing the ante, by TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx +// below, and at the unit level by +// TestEVMigrationSignerExtractionAdapter_NonMigrationTx_DelegatesToFallback. func TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx(t *testing.T) { app := lumeraapp.Setup(t) @@ -110,7 +118,43 @@ func TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) require.NotZero(t, resp.Code, - "zero-signer NON-migration tx must be rejected by CheckTx; got code=0 (security regression — adapter widened the hole)") + "zero-signer NON-migration tx must be rejected by CheckTx; got code=0 log=%q", resp.Log) + // Pin the rejecting layer: the ante's signature verification, not the + // mempool. If this assertion starts failing, the rejection moved layers + // and the comment above (and the division of coverage with the Insert + // test) needs revisiting. + require.Contains(t, resp.Log, "no signatures supplied", + "expected ante signature-verification rejection; a different layer/message means the security coverage split has shifted") +} + +// TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx is the true adapter-layer +// security pin. It drives app.GetMempool().Insert directly — bypassing the ante +// — so the SignerExtractionAdapter is actually exercised. A zero-signer +// non-migration tx must NOT receive a synthetic signer: IsEVMigrationOnlyTx is +// false for a bank message, the adapter delegates to the SDK default extractor +// (which yields zero signers), and PriorityNonceMempool.Insert then rejects +// with "tx must have at least one signer". +// +// If this test ever turns green, the adapter HAS widened the hole — a +// non-migration message type would be admitted to the mempool without envelope +// signatures, which is exactly the security regression we promised would not +// happen. This is the assertion the CheckTx test above cannot make, because the +// ante masks the mempool layer there. +func TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + from := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + to := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + bankMsg := banktypes.NewMsgSend(from, to, sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 1))) + tx := newUnsignedMigrationTxForMempool(t, app, bankMsg) + + ctx := sdk.Context{}.WithBlockHeight(1) + before := app.GetMempool().CountTx() + err := app.GetMempool().Insert(ctx, tx) + require.Error(t, err, "zero-signer non-migration tx must not be admitted to the mempool") + require.Contains(t, err.Error(), "tx must have at least one signer", + "adapter must delegate non-migration txs to the default extractor, not synthesize a signer") + require.Equal(t, before, app.GetMempool().CountTx()) } // TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode pins the @@ -133,6 +177,109 @@ func TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode(t *testing.T) require.Empty(t, sigs, "default adapter yields zero signers for migration tx — this is what makes PriorityNonceMempool.Insert reject with 'tx must have at least one signer'") } +func TestEVMMempool_SDKPriorityNonceMempoolRejectsZeroSignerMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := validMigrationMsgForMempool(t, testChainID) + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + pool := sdkmempool.NewPriorityMempool(sdkmempool.PriorityNonceMempoolConfig[int64]{}) + err := pool.Insert(sdk.Context{}.WithBlockHeight(1), tx) + require.Error(t, err) + require.Contains(t, err.Error(), "tx must have at least one signer") + require.Zero(t, pool.CountTx()) +} + +func TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := &evmigrationtypes.MsgMigrateValidator{ + NewAddress: "lumera1ttwdmmlqf8xu5mkufrh5zcck8v8yn42a5m0xpg", + LegacyAddress: testLegacyBech32, + } + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + ctx := sdk.Context{}.WithBlockHeight(1) + before := app.GetMempool().CountTx() + err := app.GetMempool().Insert(ctx, tx) + require.NoError(t, err) + require.Equal(t, before+1, app.GetMempool().CountTx()) +} + +func TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1ttwdmmlqf8xu5mkufrh5zcck8v8yn42a5m0xpg", + LegacyAddress: "not-a-bech32", + } + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + ctx := sdk.Context{}.WithBlockHeight(1) + before := app.GetMempool().CountTx() + err := app.GetMempool().Insert(ctx, tx) + require.Error(t, err) + require.Contains(t, err.Error(), "not a valid bech32") + require.Equal(t, before, app.GetMempool().CountTx()) +} + +func TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + from := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + to := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + bankMsg := banktypes.NewMsgSend(from, to, sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 1))) + migrationMsg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1ttwdmmlqf8xu5mkufrh5zcck8v8yn42a5m0xpg", + LegacyAddress: testLegacyBech32, + } + tx := newUnsignedMigrationTxForMempool(t, app, migrationMsg, bankMsg) + + ctx := sdk.Context{}.WithBlockHeight(1) + before := app.GetMempool().CountTx() + err := app.GetMempool().Insert(ctx, tx) + require.Error(t, err) + require.Contains(t, err.Error(), "tx must have at least one signer") + require.Equal(t, before, app.GetMempool().CountTx()) +} + +func TestEVMMempool_DuplicateLegacyMigrationTxDoesNotGrowMempool(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1ttwdmmlqf8xu5mkufrh5zcck8v8yn42a5m0xpg", + LegacyAddress: testLegacyBech32, + } + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + ctx := sdk.Context{}.WithBlockHeight(1) + require.NoError(t, app.GetMempool().Insert(ctx, tx)) + require.Equal(t, 1, app.GetMempool().CountTx()) + + require.NoError(t, app.GetMempool().Insert(ctx, tx)) + require.Equal(t, 1, app.GetMempool().CountTx(), "same legacy_address + sequence must remain one mempool entry") +} + +func TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := validMigrationMsgForMempool(t, testChainID) + tx := newUnsignedMigrationTxForMempool(t, app, msg) + txBytes, err := app.TxConfig().TxEncoder()(tx) + require.NoError(t, err) + + require.NoError(t, app.GetMempool().Insert(sdk.Context{}.WithBlockHeight(1), tx)) + + resp, err := app.PrepareProposal(&abci.RequestPrepareProposal{ + Height: app.LastBlockHeight() + 1, + MaxTxBytes: int64(len(txBytes) + 1024), + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Txs, 1) + require.Equal(t, txBytes, resp.Txs[0]) +} + // validMigrationMsgForMempool builds a MsgClaimLegacyAccount whose embedded // proofs pass ante-level cryptographic verification, so the only thing that // can reject the tx in CheckTx is the mempool's signer-extraction step. diff --git a/app/evm_mempool_test.go b/app/evm_mempool_test.go index 013da41f..6b92f73d 100644 --- a/app/evm_mempool_test.go +++ b/app/evm_mempool_test.go @@ -3,6 +3,8 @@ package app import ( "testing" + sdkserver "github.com/cosmos/cosmos-sdk/server" + sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" evmmempool "github.com/cosmos/evm/mempool" "github.com/stretchr/testify/require" ) @@ -24,3 +26,17 @@ func TestEVMMempoolWiringOnAppStartup(t *testing.T) { require.Same(t, getMempoolCasted, baseMempoolCasted, "App and BaseApp mempool references should match") } + +func TestEVMMempoolDisabledWhenMaxTxsIsNegative(t *testing.T) { + app, _ := setupWithAppOptionOverrides( + t, + "testing", + false, + 5, + map[string]interface{}{sdkserver.FlagMempoolMaxTxs: -1}, + ) + + require.Nil(t, app.GetMempool(), "App EVM mempool should not be configured when app-side mempool is disabled") + _, isNoOp := app.Mempool().(sdkmempool.NoOpMempool) + require.True(t, isNoOp, "BaseApp mempool should remain NoOp when app-side mempool is disabled") +} diff --git a/app/evmigration_signer_extraction_adapter.go b/app/evmigration_signer_extraction_adapter.go index 96feab0d..3fd75689 100644 --- a/app/evmigration_signer_extraction_adapter.go +++ b/app/evmigration_signer_extraction_adapter.go @@ -18,10 +18,9 @@ import ( // zero envelope signers. The Cosmos SDK mempool's default // DefaultSignerExtractionAdapter calls tx.GetSignaturesV2() and refuses any // tx whose signature set is empty (priority_nonce.go: "tx must have at -// least one signer"). That refusal happens before the ante chain runs, -// which means the migration-aware ante decorators -// (EVMigrationValidateBasicDecorator, evmigrationProofVerificationDecorator -// in app/evm/ante.go) never get a chance to admit the tx. +// least one signer"). That refusal prevents valid migration txs from being +// admitted to the app-side mempool or selected for proposals, even though +// the migration ante decorators authenticate them by proof. // // For migration-only txs we synthesize a SignerData from the message's // legacy_address: that string is a deterministic, on-chain canonical bytes @@ -63,15 +62,12 @@ func (s evmigrationSignerExtractionAdapter) GetSigners(tx sdk.Tx) ([]sdkmempool. // msg sets, but keep the invariant local. return s.fallback.GetSigners(tx) } + if len(msgs) != 1 { + return nil, fmt.Errorf("evmigration tx must contain exactly one migration message for mempool signer derivation, got %d", len(msgs)) + } - // All messages in a migration-only tx are evmigration messages with a - // legacy_address. We anchor the synthetic signer to the FIRST message's - // legacy_address; a well-formed migration tx is single-message (see - // EVMigrationValidateBasicDecorator) and submit-proof never bundles - // multiple migration messages today, so this is safe. If batching is - // ever introduced, each batch member must share the same legacy_address - // for the ordering to remain coherent, and a ValidateBasic check should - // enforce that. + // submit-proof produces a single-message tx. Keep the mempool identity + // equally narrow: one migration operation, one legacy_address bucket. legacyAddr, err := legacyAddressOfMigrationMsg(msgs[0]) if err != nil { return nil, err diff --git a/app/evmigration_signer_extraction_adapter_test.go b/app/evmigration_signer_extraction_adapter_test.go index 94273f56..c0f20a52 100644 --- a/app/evmigration_signer_extraction_adapter_test.go +++ b/app/evmigration_signer_extraction_adapter_test.go @@ -121,6 +121,23 @@ func TestEVMigrationSignerExtractionAdapter_MixedTx_DelegatesToFallback(t *testi require.Equal(t, 1, fb.called) } +func TestEVMigrationSignerExtractionAdapter_MultipleMigrationMessages_Rejected(t *testing.T) { + fb := &recordingFallback{} + adapter := newEVMigrationSignerExtractionAdapter(fb) + + tx := stubMsgsTx{ + msgs: []sdk.Msg{ + &evmigrationtypes.MsgClaimLegacyAccount{LegacyAddress: testLegacyBech32}, + &evmigrationtypes.MsgClaimLegacyAccount{LegacyAddress: testLegacyBech32}, + }, + } + + _, err := adapter.GetSigners(tx) + require.Error(t, err, "migration txs must stay single-message so mempool identity is unambiguous") + require.Contains(t, err.Error(), "exactly one migration message") + require.Zero(t, fb.called) +} + func TestEVMigrationSignerExtractionAdapter_EmptyLegacyAddress_Rejected(t *testing.T) { fb := &recordingFallback{} adapter := newEVMigrationSignerExtractionAdapter(fb) diff --git a/app/test_helpers.go b/app/test_helpers.go index a3349d25..1e4afc0b 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -209,6 +209,17 @@ func GetDefaultWasmOptions() []wasmkeeper.Option { } func setup(t testing.TB, chainID string, withGenesis bool, invCheckPeriod uint, wasmOpts ...wasmkeeper.Option) (*App, GenesisState) { + return setupWithAppOptionOverrides(t, chainID, withGenesis, invCheckPeriod, nil, wasmOpts...) +} + +func setupWithAppOptionOverrides( + t testing.TB, + chainID string, + withGenesis bool, + invCheckPeriod uint, + overrides map[string]interface{}, + wasmOpts ...wasmkeeper.Option, +) (*App, GenesisState) { db := dbm.NewMemDB() nodeHome := t.TempDir() snapshotDir := filepath.Join(nodeHome, "data", "snapshots") @@ -232,6 +243,9 @@ func setup(t testing.TB, chainID string, withGenesis bool, invCheckPeriod uint, ibcRouter.AddRoute(mockv2.PortIDA, mockv2.NewIBCModule()) ibcRouter.AddRoute(mockv2.PortIDB, mockv2.NewIBCModule()) } + for key, value := range overrides { + appOptions[key] = value + } app := New( log.NewNopLogger(), diff --git a/docs/evm-integration/architecture/rollout.md b/docs/evm-integration/architecture/rollout.md index bfba99f2..becc95bc 100644 --- a/docs/evm-integration/architecture/rollout.md +++ b/docs/evm-integration/architecture/rollout.md @@ -30,8 +30,8 @@ It covers: The implementation is already beyond the design phase. The current baseline before network rollout is: -- approximately `~397` unit tests across app wiring, ante, feemarket, precisebank, JSON-RPC, ERC20 policy, cross-runtime bridge, and `x/evmigration` -- approximately `~146` integration tests across contracts, JSON-RPC/indexer, mempool, fee market, IBC ERC20, precompiles, VM state, and `x/evmigration` +- approximately `~399` unit tests across app wiring, ante, feemarket, precisebank, JSON-RPC, ERC20 policy, cross-runtime bridge, and `x/evmigration` +- approximately `~150` integration tests across contracts, JSON-RPC/indexer, mempool, fee market, IBC ERC20, precompiles, VM state, and `x/evmigration` - multi-validator devnet tests for EVM behavior and cross-peer visibility - dedicated devnet EVM migration tests with `7` operational modes and full upgrade rehearsal: - `prepare` diff --git a/docs/evm-integration/testing/bugs.md b/docs/evm-integration/testing/bugs.md index aabb6a1c..547d6746 100644 --- a/docs/evm-integration/testing/bugs.md +++ b/docs/evm-integration/testing/bugs.md @@ -370,3 +370,17 @@ Meanwhile, the EVM keeper (initialized in `x/vm/keeper/keeper.go:119`) correctly **Fix** (`app/upgrades/v1_20_0/upgrade.go`, `app/upgrades/params/params.go`, `x/erc20policy/types/keys.go`): The upgrade handler now writes the policy mode key (`"allowlist"`) and default provenance-bound base denom trace entries after setting ERC20 params. Policy constants (`PolicyMode*`, KV keys, `DefaultAllowedBaseDenomTraces`) were moved from unexported vars in `app/` to the shared `x/erc20policy/types` package so both `app` and the upgrade handler can reference them. The `Erc20StoreKey` field was added to `AppUpgradeParams` to give the handler KV store access. Entries are stored under `PolicyAllowBaseTracePfx` with empty traces (inert placeholders). **Tests**: `TestV1200InitializesERC20ParamsWhenInitGenesisIsSkipped` extended to verify the policy mode is set to `"allowlist"` and all default base denom traces are present in the allowlist after the upgrade. + +--- + +### 26) Zero-signer evmigration tx rejected by app-side EVM mempool + +**Symptom**: `lumerad tx evmigration submit-proof tx.json` can build a valid migration tx with no Cosmos envelope signer, but broadcasting it through CheckTx fails with `tx must have at least one signer` when the app-side EVM mempool is enabled. + +**Root cause**: Migration txs intentionally declare zero SDK signers because authorization is embedded in the `legacy_proof` and `new_proof` message fields. The SDK's default `DefaultSignerExtractionAdapter` therefore returns an empty signer list. `PriorityNonceMempool.Insert` rejects empty signer data before the tx can be retained for proposal selection. + +**Fix** (`app/evmigration_signer_extraction_adapter.go`, `app/evm_mempool.go`): Added an evmigration-aware signer extraction adapter beneath the EVM-aware default. For migration-only txs it derives a deterministic synthetic signer from the message `legacy_address`; all non-migration and mixed txs delegate to the normal fallback path. Multi-message migration txs are rejected so one tx cannot map to multiple synthetic signer buckets. + +**Hardening — zero-fee mempool spam gate** (`x/evmigration/keeper/ante.go`): Admitting zero-signer migration txs to the mempool also opens a zero-fee spam vector — migration txs carry no fee and no signature, so anyone could flood the mempool/proposals with proof-valid txs that only fail at message execution. `VerifyMigrationProofsForAnte` now also enforces the migration admission window at the ante (rejects with `ErrMigrationDisabled` / `ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed), mirroring `preChecks` steps 1–2. This is a single param read (no per-account state) and is a no-op under default params (`EnableMigration=true`, `MigrationEndTime=0`); on mainnet the operator-set `MigrationEndTime` bounds the exposure to the migration window and closes it automatically. Message execution still re-checks against the canonical block time. + +**Tests**: `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx`, `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx`, `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress`, `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx`, `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx`, `TestVerifyMigrationProofsForAnte_AdmissionGate` (disabled / window-closed / open-window), and real-node `broadcast_tx_sync` coverage in `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled`, `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic`, and `TestZeroSignerNonMigrationBroadcastSyncStillRejected`. diff --git a/docs/evm-integration/testing/tests.md b/docs/evm-integration/testing/tests.md index cd25a386..3deb679c 100644 --- a/docs/evm-integration/testing/tests.md +++ b/docs/evm-integration/testing/tests.md @@ -7,7 +7,7 @@ See [main.md](main.md) for architecture, app changes, and operational details. ## Executive Summary -Lumera ships **~470 EVM-related tests** spanning unit, integration, and devnet levels — the most comprehensive pre-mainnet EVM test suite in the Cosmos ecosystem. For context: +Lumera ships **~560 EVM-related tests** spanning unit, integration, and devnet levels — the most comprehensive pre-mainnet EVM test suite in the Cosmos ecosystem. For context: - **Evmos** — the first Cosmos EVM chain — launched mainnet with primarily unit tests and a handful of end-to-end scripts; their integration test suite was built incrementally *after* mainnet issues surfaced (e.g., the zero-base-fee spam incident). - **Kava** — relied heavily on simulation tests and manual QA for their EVM launch; structured integration tests came later. @@ -18,14 +18,14 @@ Lumera's suite goes beyond any of these baselines **before** mainnet: | Capability | Lumera | Typical Cosmos EVM chain at launch | | -------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------- | | Dual-route ante handler tests (EVM + Cosmos path) | 28 unit + 3 integration | Rarely tested separately | -| App-side mempool (ordering, nonce gaps, replacement, capacity, WS subscriptions, metrics) | 16 integration + 10 unit (metrics) | None (relies on CometBFT mempool) | +| App-side mempool (ordering, nonce gaps, replacement, capacity, WS subscriptions, metrics, evmigration zero-signer admission) | 19 integration + 10 unit (metrics) | None (relies on CometBFT mempool) | | Async broadcast queue (deadlock prevention) | 4 unit | Not applicable (novel to Lumera) | | JSON-RPC batching, persistence across restart | 23 integration | Basic RPC smoke tests | | ERC20/IBC middleware (v1 + v2 stacks) | 7 integration + 14 unit (policy) | Partial or post-launch | | Precisebank (6↔18 decimal bridge) | 39 unit + 6 integration | Not applicable (novel to Lumera) | | Feemarket (EIP-1559) | 9 unit + 8 integration | Inherited from upstream, rarely augmented | | Precompile coverage (11 precompiles + gas metering + action + supernode + wasm) | 42+ integration | Smoke-level | -| Account migration (coin-type 118→60) | 117 unit + 15 integration + devnet tool | Not applicable (novel to Lumera) | +| Account migration (coin-type 118→60) | 117+ keeper/CLI unit + app-level mempool regression tests + 18 integration + devnet tool | Not applicable (novel to Lumera) | | OpenRPC discovery + spec sync | 15 unit + 2 integration | No chain has this | | WebSocket subscriptions (newHeads, logs, pending) | 4 integration | Untested or manual | | Cross-runtime bridge (CosmWasm ↔ EVM) | 12 integration + 31 unit + 15 crossruntime unit | No chain has this | @@ -43,7 +43,7 @@ All three previously identified critical test gaps (mempool capacity pressure, b | Category | Area | Tests | Coverage quality | | --------------- | ------------------------------------ | ----- | ---------------- | -| **Unit** | App wiring/config/genesis/commands | 72 | Excellent — [details](tests/unit-app-wiring.md) | +| **Unit** | App wiring/config/genesis/commands | 73 | Excellent — [details](tests/unit-app-wiring.md) | | **Unit** | EVM ante decorators | 28 | Excellent — [details](tests/unit-ante.md) | | **Unit** | EVM module/config guard/genesis | 7 | High — [details](tests/unit-evm-config.md) | | **Unit** | Fee market | 9 | Excellent — [details](tests/unit-feemarket.md) | @@ -61,16 +61,16 @@ All three previously identified critical test gaps (mempool capacity pressure, b | **Integration** | Fee market | 8 | Excellent — [details](tests/integration-feemarket.md) | | **Integration** | IBC ERC20 | 7 | High — [details](tests/integration-ibc-erc20.md) | | **Integration** | JSON-RPC / indexer | 23 | Very high — [details](tests/integration-jsonrpc.md) | -| **Integration** | Mempool | 16 | High — [details](tests/integration-mempool.md) | +| **Integration** | Mempool | 19 | High — [details](tests/integration-mempool.md) | | **Integration** | Precisebank | 6 | High — [details](tests/integration-precisebank.md) | | **Integration** | Precompiles (standard + custom + wasm) | 42 | High — [details](tests/integration-precompiles.md) | | **Integration** | VM queries / state | 12 | High — [details](tests/integration-vm.md) | -| **Integration** | EVMigration | 15+ | High — [details](tests/integration-evmigration.md) | +| **Integration** | EVMigration | 15+ core + 3 mempool broadcast regressions | High — [details](tests/integration-evmigration.md) | | | | | | | **Devnet** | EVM / fee market / cross-peer / IBC | 12+ | High — [details](tests/devnet.md) | | **Devnet** | EVMigration tool | 7 modes | High — [details](tests/devnet.md#evm-migration-devnet-tests) | | | | | | -| | **Totals** | **Unit: ~398 · Integration: ~147 · Devnet: 12+ · Total: ~557** | | +| | **Totals** | **Unit: ~399 · Integration: ~150 · Devnet: 12+ · Total: ~561** | | ### Gaps and next steps @@ -109,7 +109,7 @@ Each area has its own detailed file with per-test descriptions: | Area | File | Tests | | ---- | ---- | ----- | -| App wiring, config, genesis, commands | [unit-app-wiring.md](tests/unit-app-wiring.md) | 72 | +| App wiring, config, genesis, commands | [unit-app-wiring.md](tests/unit-app-wiring.md) | 73 | | EVM ante decorators | [unit-ante.md](tests/unit-ante.md) | 28 | | EVM module/config guard/genesis | [unit-evm-config.md](tests/unit-evm-config.md) | 7 | | Fee market (EIP-1559) | [unit-feemarket.md](tests/unit-feemarket.md) | 9 | @@ -128,11 +128,11 @@ Each area has its own detailed file with per-test descriptions: | Fee market (EIP-1559) | [integration-feemarket.md](tests/integration-feemarket.md) | 8 | | IBC ERC20 middleware | [integration-ibc-erc20.md](tests/integration-ibc-erc20.md) | 7 | | JSON-RPC & indexer | [integration-jsonrpc.md](tests/integration-jsonrpc.md) | 23 | -| Mempool | [integration-mempool.md](tests/integration-mempool.md) | 16 | +| Mempool | [integration-mempool.md](tests/integration-mempool.md) | 19 | | Precisebank | [integration-precisebank.md](tests/integration-precisebank.md) | 6 | | Precompiles (standard + custom + wasm + crossruntime) | [integration-precompiles.md](tests/integration-precompiles.md) | 42 | | VM queries / state | [integration-vm.md](tests/integration-vm.md) | 12 | -| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 15+ | +| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 15+ core + 3 mempool broadcast regressions | ### Devnet Tests diff --git a/docs/evm-integration/testing/tests/integration-evmigration.md b/docs/evm-integration/testing/tests/integration-evmigration.md index 45428489..b0bf1ebf 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -4,6 +4,9 @@ Purpose: end-to-end integration tests for the `x/evmigration` module using real File: `tests/integration/evmigration/migration_test.go` Run: `go test -tags=test ./tests/integration/evmigration/... -v` +Additional real-node broadcast coverage for zero-signer `submit-proof` txs lives in the EVM mempool suite: +`tests/integration/evm/mempool/evmigration_zero_signer_test.go`. Those tests start a `lumerad` node, wait for height 1, and submit encoded tx bytes through CometBFT `broadcast_tx_sync`. + | Test | Description | | --- | --- | | `TestClaimLegacyAccount_Success` | End-to-end migration: balances move, migration record stored, counter incremented. | @@ -20,3 +23,6 @@ Run: `go test -tags=test ./tests/integration/evmigration/... -v` | `TestMigrateValidator_JailedValidator` | Rejection when validator is jailed with real staking/auth state; asserts no migration record or destination validator is created. | | `TestQueryMigrationRecord_Integration` | Query server returns record after real migration, nil before. | | `TestQueryMigrationEstimate_Integration` | Estimate query with real staking state reports correct values. | +| `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Mempool-suite regression: valid zero-signer migration tx passes real-node CheckTx with app-side mempool enabled. | +| `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic` | Mempool-suite negative test: malformed `legacy_address` is rejected by `ValidateBasic` in the ante chain on the real-node broadcast path (before mempool admission). | +| `TestZeroSignerNonMigrationBroadcastSyncStillRejected` | Mempool-suite negative control: zero-signer non-migration tx remains rejected. | diff --git a/docs/evm-integration/testing/tests/integration-mempool.md b/docs/evm-integration/testing/tests/integration-mempool.md index 69f4f4ba..ec4c7690 100644 --- a/docs/evm-integration/testing/tests/integration-mempool.md +++ b/docs/evm-integration/testing/tests/integration-mempool.md @@ -6,6 +6,7 @@ Suites: - `tests/integration/evm/mempool/suite_test.go` - `tests/integration/evm/mempool/metrics_txpool_status_test.go` - `tests/integration/evm/mempool/metrics_prometheus_e2e_test.go` +- `tests/integration/evm/mempool/evmigration_zero_signer_test.go` | Test | Description | | --- | --- | @@ -24,3 +25,6 @@ Suites: | `TestTxPoolStatusOverflowKeepsPoolBounded` | Verifies flooding a low-capacity mempool results in rejections and bounded pool size. | | `TestPrometheusMetricsExposeMempoolGauges` | E2E: starts node with Prometheus telemetry, scrapes /metrics, verifies gauges. | | `TestPrometheusRejectionsCountedViaCometCheckTx` | E2E: submits malformed bytes via CometBFT broadcast_tx_sync, verifies rejection counter. | +| `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Real-node `broadcast_tx_sync`: a valid zero-signer `MsgClaimLegacyAccount` passes CheckTx with the app-side EVM mempool enabled. | +| `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic` | Real-node `broadcast_tx_sync`: malformed migration `legacy_address` is rejected by `ValidateBasic` in the ante chain, before mempool admission. | +| `TestZeroSignerNonMigrationBroadcastSyncStillRejected` | Negative control: a zero-signer non-migration tx is still rejected, proving the evmigration adapter does not widen signer bypass behavior. | diff --git a/docs/evm-integration/testing/tests/unit-app-wiring.md b/docs/evm-integration/testing/tests/unit-app-wiring.md index 48bb3a81..2e6a6c07 100644 --- a/docs/evm-integration/testing/tests/unit-app-wiring.md +++ b/docs/evm-integration/testing/tests/unit-app-wiring.md @@ -39,6 +39,7 @@ Primary files: | `TestBlockedAddressesMatrix` | Verifies blocked-address set contains expected module/precompile addresses. | | `TestPrecompileSendRestriction` | Verifies bank send restriction blocks sends to EVM precompile addresses. | | `TestEVMMempoolWiringOnAppStartup` | Verifies app-side EVM mempool wiring occurs at startup with expected handlers. | +| `TestEVMMempoolDisabledWhenMaxTxsIsNegative` | Verifies `mempool.max-txs = -1` leaves the app-side EVM mempool disabled and BaseApp on `NoOpMempool`. | | `TestEVMMempoolReentrantInsertBlocks` | Demonstrates mutex re-entry hazard that the async broadcast queue prevents. | | `TestConfigureEVMBroadcastOptionsFromAppOptions` | Verifies broadcast debug flag parsing from app options (bool, string, nil). | | `TestEVMTxBroadcastDispatcherDedupesQueuedAndInFlight` | Verifies dispatcher deduplicates queued and in-flight tx hashes. | diff --git a/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index 519f772e..a414e9f6 100644 --- a/docs/evm-integration/testing/tests/unit-evmigration.md +++ b/docs/evm-integration/testing/tests/unit-evmigration.md @@ -123,6 +123,33 @@ Files: `x/evmigration/keeper/verify_test.go`, `x/evmigration/keeper/migrate_test **Additional regression coverage**: `TestKeeper_GetSuperNodeByAccount` (in `x/supernode/v1/keeper/`) confirms `GetSuperNodeByAccount` returns the correct supernode for a given account address, exercising the index used by `MigrateSupernode`. +## App-side mempool signer adapter tests + +Migration txs are intentionally zero-signer at the Cosmos tx envelope layer; authorization lives in the embedded legacy and new-address proofs. The app-level tests below cover the mempool-specific signer adapter that lets those txs pass app-side mempool admission without weakening non-migration tx validation. + +Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evmigration_test.go` + +| Test | Description | +| ---- | ----------- | +| `TestEVMigrationSignerExtractionAdapter_ClaimLegacyAccount` | Extracts a deterministic synthetic signer from `legacy_address` for `MsgClaimLegacyAccount`. | +| `TestEVMigrationSignerExtractionAdapter_MigrateValidator` | Extracts the same synthetic signer shape for `MsgMigrateValidator`. | +| `TestEVMigrationSignerExtractionAdapter_NonMigration_DelegatesToFallback` | Non-migration txs keep the normal fallback signer extraction path. | +| `TestEVMigrationSignerExtractionAdapter_MixedTx_DelegatesToFallback` | Mixed migration + non-migration txs are not treated as migration-only. | +| `TestEVMigrationSignerExtractionAdapter_MultipleMigrationMessages_Rejected` | Multi-message migration txs are rejected so one tx cannot map to multiple synthetic signer buckets. | +| `TestEVMigrationSignerExtractionAdapter_EmptyLegacyAddress_Rejected` | Empty `legacy_address` cannot produce a mempool signer. | +| `TestEVMigrationSignerExtractionAdapter_InvalidLegacyAddress_Rejected` | Malformed bech32 `legacy_address` is rejected before mempool insertion. | +| `TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode` | Pins the upstream SDK default extractor behavior: zero-signer migration txs produce no signers. | +| `TestEVMMempool_SDKPriorityNonceMempoolRejectsZeroSignerMigrationTx` | Demonstrates the raw SDK `PriorityNonceMempool` rejection that the app adapter fixes. | +| `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx` | Full app CheckTx path accepts a valid zero-signer migration tx. | +| `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx` | End-to-end pin: zero-signer non-migration txs are rejected on the live CheckTx path (by the ante's signature verification, before mempool admission). | +| `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx` | Adapter-layer security pin: drives `mempool.Insert` directly (bypassing the ante) to prove a non-migration tx gets no synthetic signer and is rejected with "tx must have at least one signer". | +| `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx` | App mempool accepts zero-signer `MsgMigrateValidator`. | +| `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress` | App mempool rejects malformed migration `legacy_address`. | +| `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx` | Mixed migration/non-migration txs do not get synthetic signer treatment. | +| `TestEVMMempool_DuplicateLegacyMigrationTxDoesNotGrowMempool` | Duplicate txs for the same synthetic legacy-address signer do not grow the mempool. | +| `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx` | Accepted zero-signer migration txs are selected by `PrepareProposal`. | +| `TestVerifyMigrationProofsForAnte_AdmissionGate` | Admission gate: proof-valid migration txs are rejected at the ante (`ErrMigrationDisabled` / `ErrMigrationWindowClosed`) when migration is off or the window has closed, bounding the zero-fee mempool-spam surface to the operator-defined window. | + ## Multisig support tests ### Multisig verifier tests (`x/evmigration/keeper/verify_test.go`) diff --git a/docs/evm-integration/user-guides/migration.md b/docs/evm-integration/user-guides/migration.md index 8f7cc035..27baa83e 100644 --- a/docs/evm-integration/user-guides/migration.md +++ b/docs/evm-integration/user-guides/migration.md @@ -598,7 +598,7 @@ Multisig legacy accounts (flat K-of-N `secp256k1`) use an offline, coordinator-d > - **Shape + K/N must mirror.** A K-of-N legacy multisig migrates to a K-of-N`eth_secp256k1` multisig — same K, same N. Different K, different N, or single↔multisig shape mismatch is rejected with`ErrMirrorSourceMismatch` (code 1121). > - **Same K signer positions sign both halves.**`legacy_proof.signer_indices` must equal`new_proof.signer_indices`. Co-signers who sign only one side don't count toward the K-of-K threshold on the other. > - **Sub-key uniqueness.** Each side's`sub_pub_keys` must have pairwise-distinct entries. -> - **Zero-signer submit.**`submit-proof` takes no`--from`, no fee flags, no envelope signature — authorization is the proof bytes. Mempool acceptance of zero-signer migration txs requires `app/evmigration_signer_extraction_adapter.go` to be wired into the EVM mempool's `CosmosPoolConfig.SignerExtractor`; without it, `ExperimentalEVMMempool` falls back to the SDK's default extractor and rejects the tx with `tx must have at least one signer` before the migration-aware ante chain runs. +> - **Zero-signer submit.**`submit-proof` takes no`--from`, no fee flags, no envelope signature — authorization is the proof bytes. Mempool acceptance of zero-signer migration txs requires `app/evmigration_signer_extraction_adapter.go` to be wired into the EVM mempool's `CosmosPoolConfig.SignerExtractor`; without it, `ExperimentalEVMMempool` falls back to the SDK's default extractor and rejects the tx with `tx must have at least one signer` during app-side mempool admission/proposal selection. > > Full reference with error codes and helper functions: [legacy-migration.md § Consensus invariants](../evmigration/legacy-migration.md#consensus-invariants). diff --git a/tests/integration/evm/mempool/evmigration_zero_signer_test.go b/tests/integration/evm/mempool/evmigration_zero_signer_test.go new file mode 100644 index 00000000..1a502de3 --- /dev/null +++ b/tests/integration/evm/mempool/evmigration_zero_signer_test.go @@ -0,0 +1,173 @@ +//go:build integration +// +build integration + +package mempool_test + +import ( + "context" + "crypto/sha256" + "fmt" + "strings" + "testing" + "time" + + rpchttp "github.com/cometbft/cometbft/rpc/client/http" + coretypes "github.com/cometbft/cometbft/rpc/core/types" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + evmcryptotypes "github.com/cosmos/evm/crypto/ethsecp256k1" + "github.com/stretchr/testify/require" + + lumeraapp "github.com/LumeraProtocol/lumera/app" + lcfg "github.com/LumeraProtocol/lumera/config" + evmtest "github.com/LumeraProtocol/lumera/tests/integration/evmtest" + evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +func TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled(t *testing.T) { + node := evmtest.NewEVMNode(t, "lumera-evmigration-mempool", 20) + node.StartAndWaitRPC() + defer node.Stop() + node.WaitForBlockNumberAtLeast(t, 1, 20*time.Second) + + txBytes := validZeroSignerMigrationTxBytes(t, node.ChainID()) + res := broadcastSync(t, node, txBytes) + + require.Zero(t, res.Code, "zero-signer migration tx must pass CheckTx with app-side mempool enabled: %s", res.Log) + require.NotContains(t, res.Log, "tx must have at least one signer") +} + +// TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic confirms that a +// migration tx carrying a non-bech32 legacy_address is rejected end-to-end on a +// real node. +// +// NOTE ON LAYERING: this rejection comes from MsgClaimLegacyAccount.ValidateBasic +// ("invalid legacy_address", x/evmigration/types/types.go), which runs in the +// ante chain *before* mempool admission. The malformed address therefore never +// reaches the signer-extraction adapter's own bech32 guard — ValidateBasic +// shadows it. The adapter's "not a valid bech32" branch is exercised directly, +// without the ante in front of it, by the in-process test +// TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress in +// app/evm_mempool_evmigration_test.go. This test is the complementary +// end-to-end check that a malformed migration tx is rejected on the live path. +func TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic(t *testing.T) { + node := evmtest.NewEVMNode(t, "lumera-evmigration-bad-legacy", 20) + node.StartAndWaitRPC() + defer node.Stop() + node.WaitForBlockNumberAtLeast(t, 1, 20*time.Second) + + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + NewAddress: "lumera1ttwdmmlqf8xu5mkufrh5zcck8v8yn42a5m0xpg", + LegacyAddress: "not-a-bech32", + LegacyProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: secp256k1.GenPrivKey().PubKey().Bytes(), + Signature: []byte("bad"), + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + NewProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: make([]byte, 33), + Signature: []byte("bad"), + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + } + txBytes := unsignedTxBytes(t, msg) + res := broadcastSync(t, node, txBytes) + + require.NotZero(t, res.Code) + require.Contains(t, res.Log, "invalid legacy_address", + "malformed legacy_address must be rejected by ValidateBasic in the ante chain, before mempool admission") + // And it must NOT be the mempool's zero-signer rejection: ValidateBasic + // fires first, so the signer-extraction layer is never reached here. + require.NotContains(t, res.Log, "at least one signer") +} + +func TestZeroSignerNonMigrationBroadcastSyncStillRejected(t *testing.T) { + node := evmtest.NewEVMNode(t, "lumera-evmigration-nonmigration", 20) + node.StartAndWaitRPC() + defer node.Stop() + node.WaitForBlockNumberAtLeast(t, 1, 20*time.Second) + + from := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + to := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address().Bytes()) + msg := banktypes.NewMsgSend(from, to, sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 1))) + txBytes := unsignedTxBytes(t, msg) + res := broadcastSync(t, node, txBytes) + + require.NotZero(t, res.Code) + require.True(t, + strings.Contains(res.Log, "no signatures") || strings.Contains(res.Log, "at least one signer"), + "zero-signer non-migration tx must be rejected for missing signer data, got log: %s", res.Log, + ) +} + +func validZeroSignerMigrationTxBytes(t *testing.T, chainID string) []byte { + t.Helper() + + legacyPriv := secp256k1.GenPrivKey() + newPriv, err := evmcryptotypes.GenerateKey() + require.NoError(t, err) + + legacy := sdk.AccAddress(legacyPriv.PubKey().Address().Bytes()) + newAddr := sdk.AccAddress(newPriv.PubKey().Address().Bytes()) + require.False(t, legacy.Equals(newAddr)) + + payload := []byte(fmt.Sprintf( + "lumera-evm-migration:%s:%d:claim:%s:%s", + chainID, + lcfg.EVMChainID, + legacy.String(), + newAddr.String(), + )) + legacyHash := sha256.Sum256(payload) + legacySig, err := legacyPriv.Sign(legacyHash[:]) + require.NoError(t, err) + + newSig, err := newPriv.Sign(payload) + require.NoError(t, err) + + msg := &evmigrationtypes.MsgClaimLegacyAccount{ + LegacyAddress: legacy.String(), + NewAddress: newAddr.String(), + LegacyProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: legacyPriv.PubKey().Bytes(), + Signature: legacySig, + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + NewProof: evmigrationtypes.MigrationProof{Proof: &evmigrationtypes.MigrationProof_Single{Single: &evmigrationtypes.SingleKeyProof{ + PubKey: newPriv.PubKey().Bytes(), + Signature: newSig, + SigFormat: evmigrationtypes.SigFormat_SIG_FORMAT_CLI, + }}}, + } + return unsignedTxBytes(t, msg) +} + +func unsignedTxBytes(t *testing.T, msgs ...sdk.Msg) []byte { + t.Helper() + + encCfg := lumeraapp.MakeEncodingConfig(t) + txBuilder := encCfg.TxConfig.NewTxBuilder() + require.NoError(t, txBuilder.SetMsgs(msgs...)) + txBuilder.SetGasLimit(200_000) + + txBytes, err := encCfg.TxConfig.TxEncoder()(txBuilder.GetTx()) + require.NoError(t, err) + return txBytes +} + +func broadcastSync(t *testing.T, node *evmtest.Node, txBytes []byte) *coretypes.ResultBroadcastTx { + t.Helper() + + client, err := rpchttp.New(node.CometRPCURL(), "/websocket") + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + res, err := client.BroadcastTxSync(ctx, cmttypes.Tx(txBytes)) + require.NoError(t, err) + require.NotNil(t, res) + return res +} diff --git a/x/evmigration/keeper/ante.go b/x/evmigration/keeper/ante.go index 6deab018..d87ce3cb 100644 --- a/x/evmigration/keeper/ante.go +++ b/x/evmigration/keeper/ante.go @@ -2,6 +2,7 @@ package keeper import ( "fmt" + "time" sdk "github.com/cosmos/cosmos-sdk/types" @@ -10,8 +11,22 @@ import ( "github.com/LumeraProtocol/lumera/x/evmigration/types/sigverify" ) -// VerifyMigrationProofsForAnte performs the same proof checks as msg execution -// before fee-free, unsigned migration txs are admitted to the mempool. +// VerifyMigrationProofsForAnte gates fee-free, unsigned migration txs at the +// ante — before they are admitted to the app mempool or selected for proposals. +// +// It enforces two things: +// +// 1. The migration admission window. Migration txs carry no fee and no +// envelope signature, so without this gate anyone could flood the mempool +// and proposals with zero-fee migration txs at any time. Rejecting txs at +// the ante when migration is disabled or the window has closed bounds that +// exposure to the operator-defined migration window. (Mirrors preChecks +// steps 1–2 in msg_server_claim_legacy.go; message execution re-checks +// against the canonical block time, so this is a best-effort mempool +// filter, not the authoritative gate.) +// +// 2. The same cryptographic proof checks message execution performs, so a tx +// with invalid embedded proofs never reaches the mempool. func (k Keeper) VerifyMigrationProofsForAnte(ctx sdk.Context, msg sdk.Msg) error { var kind string var legacyAddress string @@ -49,6 +64,16 @@ func (k Keeper) VerifyMigrationProofsForAnte(ctx sdk.Context, msg sdk.Msg) error if err != nil { return err } + + // Admission gate: keep zero-fee, zero-signature migration txs out of the + // mempool once migration is switched off or the window has closed. + if !params.EnableMigration { + return types.ErrMigrationDisabled + } + if params.MigrationEndTime > 0 && ctx.BlockTime().After(time.Unix(params.MigrationEndTime, 0)) { + return types.ErrMigrationWindowClosed + } + if err := legacyProof.ValidateParams(params.MaxMultisigSubKeys); err != nil { return err } diff --git a/x/evmigration/keeper/ante_test.go b/x/evmigration/keeper/ante_test.go index 80dd6881..023eb46b 100644 --- a/x/evmigration/keeper/ante_test.go +++ b/x/evmigration/keeper/ante_test.go @@ -3,11 +3,14 @@ package keeper_test import ( "strings" "testing" + "time" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" sdk "github.com/cosmos/cosmos-sdk/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" "github.com/stretchr/testify/require" + + "github.com/LumeraProtocol/lumera/x/evmigration/types" ) func TestVerifyMigrationProofsForAnte(t *testing.T) { @@ -53,3 +56,48 @@ func TestVerifyMigrationProofsForAnte(t *testing.T) { require.Contains(t, err.Error(), "unsupported evmigration ante message type") }) } + +// TestVerifyMigrationProofsForAnte_AdmissionGate pins the mempool admission +// gate: when migration is disabled or the window has closed, a proof-valid +// migration tx must be rejected at the ante — before mempool insertion — so +// zero-fee migration txs cannot flood the mempool outside the operator-defined +// window. This is the cheap defense (a single param read, no per-account state) +// against the zero-fee spam vector opened by admitting zero-signer migration +// txs (PR #167). +func TestVerifyMigrationProofsForAnte_AdmissionGate(t *testing.T) { + legacyPriv := secp256k1.GenPrivKey() + legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address()) + newPriv, newAddr := testNewMigrationAccount(t) + + t.Run("rejected when migration disabled", func(t *testing.T) { + fixture := initMsgServerFixture(t) + // EnableMigration=false; otherwise default params. + require.NoError(t, fixture.keeper.Params.Set(fixture.ctx, types.NewParams(false, 0, 50, 2000, 20))) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) + require.ErrorIs(t, err, types.ErrMigrationDisabled) + }) + + t.Run("rejected when window closed", func(t *testing.T) { + fixture := initMsgServerFixture(t) + // Window ends at unix 1000; block time is well past it. + require.NoError(t, fixture.keeper.Params.Set(fixture.ctx, types.NewParams(true, 1000, 50, 2000, 20))) + ctx := fixture.ctx.WithBlockTime(time.Unix(2000, 0)) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(ctx, msg) + require.ErrorIs(t, err, types.ErrMigrationWindowClosed) + }) + + t.Run("accepted inside open window before proof checks change nothing", func(t *testing.T) { + fixture := initMsgServerFixture(t) + // Window ends at unix 5000; block time is before it -> gate passes, + // valid proofs accepted. + require.NoError(t, fixture.keeper.Params.Set(fixture.ctx, types.NewParams(true, 5000, 50, 2000, 20))) + ctx := fixture.ctx.WithBlockTime(time.Unix(1000, 0)) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + require.NoError(t, fixture.keeper.VerifyMigrationProofsForAnte(ctx, msg)) + }) +} From 1131aaf4d9afb6e09a98d8519f8222593f0c4470 Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 18 Jun 2026 21:21:29 -0400 Subject: [PATCH 4/5] docs(evmigration): correct stale signer-adapter test names in test catalog The adapter rows in unit-evmigration.md referenced names that no longer match the actual test functions in app/evmigration_signer_extraction_adapter_test.go: _ClaimLegacyAccount -> _MigrationOnlyTx_SyntheticSigner _MigrateValidator -> _MigrationOnlyTx_MigrateValidator _NonMigration_DelegatesToFallback -> _NonMigrationTx_DelegatesToFallback _InvalidLegacyAddress_Rejected -> _InvalidBech32_Rejected and the _NilFallback_FallsBackToDefault test was missing entirely. Names now match the source 1:1. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/evm-integration/testing/tests/unit-evmigration.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index a414e9f6..ee2a8ae0 100644 --- a/docs/evm-integration/testing/tests/unit-evmigration.md +++ b/docs/evm-integration/testing/tests/unit-evmigration.md @@ -131,13 +131,14 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | Test | Description | | ---- | ----------- | -| `TestEVMigrationSignerExtractionAdapter_ClaimLegacyAccount` | Extracts a deterministic synthetic signer from `legacy_address` for `MsgClaimLegacyAccount`. | -| `TestEVMigrationSignerExtractionAdapter_MigrateValidator` | Extracts the same synthetic signer shape for `MsgMigrateValidator`. | -| `TestEVMigrationSignerExtractionAdapter_NonMigration_DelegatesToFallback` | Non-migration txs keep the normal fallback signer extraction path. | +| `TestEVMigrationSignerExtractionAdapter_MigrationOnlyTx_SyntheticSigner` | Extracts a deterministic synthetic signer from `legacy_address` for `MsgClaimLegacyAccount`. | +| `TestEVMigrationSignerExtractionAdapter_MigrationOnlyTx_MigrateValidator` | Extracts the same synthetic signer shape for `MsgMigrateValidator`. | +| `TestEVMigrationSignerExtractionAdapter_NonMigrationTx_DelegatesToFallback` | Non-migration txs keep the normal fallback signer extraction path. | | `TestEVMigrationSignerExtractionAdapter_MixedTx_DelegatesToFallback` | Mixed migration + non-migration txs are not treated as migration-only. | | `TestEVMigrationSignerExtractionAdapter_MultipleMigrationMessages_Rejected` | Multi-message migration txs are rejected so one tx cannot map to multiple synthetic signer buckets. | | `TestEVMigrationSignerExtractionAdapter_EmptyLegacyAddress_Rejected` | Empty `legacy_address` cannot produce a mempool signer. | -| `TestEVMigrationSignerExtractionAdapter_InvalidLegacyAddress_Rejected` | Malformed bech32 `legacy_address` is rejected before mempool insertion. | +| `TestEVMigrationSignerExtractionAdapter_InvalidBech32_Rejected` | Malformed bech32 `legacy_address` is rejected before mempool insertion. | +| `TestEVMigrationSignerExtractionAdapter_NilFallback_FallsBackToDefault` | Nil fallback is replaced with the SDK default adapter without panicking. | | `TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode` | Pins the upstream SDK default extractor behavior: zero-signer migration txs produce no signers. | | `TestEVMMempool_SDKPriorityNonceMempoolRejectsZeroSignerMigrationTx` | Demonstrates the raw SDK `PriorityNonceMempool` rejection that the app adapter fixes. | | `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx` | Full app CheckTx path accepts a valid zero-signer migration tx. | From f486fb9379253ebd396542bfe99976d5c020021e Mon Sep 17 00:00:00 2001 From: Andrey Kobrin Date: Thu, 18 Jun 2026 21:48:32 -0400 Subject: [PATCH 5/5] fix(evmigration): extend ante admission gate with cheap state checks + align tests/docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the migration-window gate by adding the cheap state-plausibility checks to the ante, so proof-valid-but-impossible zero-fee migration txs are rejected before mempool admission rather than only at message execution. This closes the in-window leg of the zero-fee mempool-spam vector: a fabricated keypair has no on-chain legacy account, so it is now rejected at the ante. Implementation (x/evmigration/keeper/ante.go): - VerifyMigrationProofsForAnte now runs verifyMigrationAdmissionState after the enable/window gate and before proof verification. It mirrors the cheap subset of msgServer.preChecks: addresses-differ, source-not-already-migrated, new-address-not-a-migrated-legacy, destination-not-reused, legacy-account exists and is not a module account, and (for MsgMigrateValidator) the source is a validator operator. The per-block rate limit is intentionally omitted from the ante (it is block-stateful and belongs only at execution). - Ordering matters: state checks run before proof verification, so an invalid proof on a nonexistent account surfaces the state error first. The keeper still re-checks everything at execution; the ante is a best-effort mempool filter. Tests: - x/evmigration/keeper/ante_test.go: TestVerifyMigrationProofsForAnte_AdmissionGate (disabled / window-closed / open-window) and _CheapStateAdmission (nonexistent legacy, already-migrated, reused destination, non-validator source), with mock expectations pinning the check ordering. Existing subtests seed the legacy account so they exercise the proof paths they intend to. - app/evm_mempool_evmigration_test.go: seed the legacy account into the check-tx state (NewContext(true) — the state CheckTx reads; NewContext(false) targets finalizeBlockState and is invisible to CheckTx) using NewAccountWithAddress to assign a fresh account number; PrepareProposal test uses a genesis-seeded legacy account so the proposal-time ante verify passes. - app/evm/ante_evmigration_fee_test.go: add seedLegacyAccountInCtx and seed the legacy account in the accept / invalid-proof / CheckTx cases so the state gate passes and the proof-rejection path is what is actually asserted. - tests/integration/evm/mempool/evmigration_zero_signer_test.go: seed the legacy account into the node genesis before broadcasting (real-node path). Docs: - docs/.../tests/unit-evmigration.md: remap 27 stale signature/multisig test names to the current functions (TestVerifyCosmosSecp256k1_* / TestVerifyEthSecp256k1_* for signature verification, TestVerifyMigrationProof_ NewSide_Multisig_* for the multisig verifier, NonSecp256k1SubKey, MigrationProof_ValidateBasic_Dispatch). Every referenced name now resolves to a real func. - docs/.../tests.md: correct counters to actual values — EVMigration keeper 118+ -> 124+, EVMigration integration "15+ core" -> "14 core + 4 mempool broadcast regressions" (18 rows), comparison row 117+/19 -> 150+/18, totals Unit ~401/Int ~151/Total ~564 -> ~407/~150/~569, headline ~560 -> ~570. - CHANGELOG.md, bugs.md, integration-{evmigration,mempool}.md: describe the state-plausibility checks and the new negative tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- app/evm/ante_evmigration_fee_test.go | 26 +++++- app/evm_mempool_evmigration_test.go | 76 +++++++++++++-- docs/evm-integration/testing/bugs.md | 4 +- docs/evm-integration/testing/tests.md | 20 ++-- .../testing/tests/integration-evmigration.md | 1 + .../testing/tests/integration-mempool.md | 1 + .../testing/tests/unit-evmigration.md | 49 +++++----- .../mempool/evmigration_zero_signer_test.go | 65 ++++++++++++- x/evmigration/keeper/ante.go | 57 ++++++++++++ x/evmigration/keeper/ante_test.go | 93 ++++++++++++++++++- 11 files changed, 339 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 253871b4..0087c17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ Full EVM integration documentation: [docs/evm-integration/main.md](docs/evm-inte - Added `devnet/scripts/lumera-helper.sh unjail-validator` helper plus downtime warnings in the validator migration guide for operators approaching the slashing window. - Added fee-waiving ante decorator for migration txs (`ante/evmigration_fee_decorator.go`) since new addresses have zero balance pre-migration. - Added migration-aware mempool signer extractor (`app/evmigration_signer_extraction_adapter.go`) wired into `ExperimentalEVMMempool.CosmosPoolConfig.SignerExtractor`. Without it, the SDK's default `DefaultSignerExtractionAdapter` rejects zero-signer migration txs (`MsgClaimLegacyAccount`, `MsgMigrateValidator`) with "tx must have at least one signer" during app-side mempool admission/proposal selection, blocking `submit-proof` broadcast. The adapter synthesizes a deterministic signer from the message's `legacy_address` for migration-only txs and delegates everything else to the EVM-aware default. -- Added regression coverage for zero-signer evmigration tx admission: unit tests pin the upstream SDK mempool rejection, adapter fallback and negative cases (malformed `legacy_address`, multi-message and mixed-message txs), app tests cover `PrepareProposal` inclusion and disabled-mempool wiring, and real-node integration tests broadcast `submit-proof`-style tx bytes through CometBFT `broadcast_tx_sync`. -- Hardened the migration ante (`x/evmigration/keeper/ante.go`) to enforce the migration admission window before mempool admission: since migration txs are fee-free and signature-free, `VerifyMigrationProofsForAnte` now rejects them with `ErrMigrationDisabled`/`ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed. This bounds the zero-fee mempool-spam surface to the operator-defined window (no-op under default params; mainnet sets a concrete `MigrationEndTime`). +- Added regression coverage for zero-signer evmigration tx admission: unit tests pin the upstream SDK mempool rejection, adapter fallback and negative cases (malformed `legacy_address`, nonexistent legacy accounts, multi-message and mixed-message txs), app tests cover `PrepareProposal` inclusion and disabled-mempool wiring, and real-node integration tests broadcast `submit-proof`-style tx bytes through CometBFT `broadcast_tx_sync`. +- Hardened the migration ante (`x/evmigration/keeper/ante.go`) to enforce the migration admission window and cheap state plausibility before mempool admission: since migration txs are fee-free and signature-free, `VerifyMigrationProofsForAnte` now rejects them with `ErrMigrationDisabled`/`ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed, and rejects proof-valid but impossible migrations such as nonexistent legacy accounts, already-migrated sources, reused destination addresses, and non-validator `MsgMigrateValidator` sources. This bounds the zero-fee mempool-spam surface to the operator-defined window (no-op under default params; mainnet sets a concrete `MigrationEndTime`) and avoids retaining txs that would fail immediately at message execution. - Added v1.20.0 upgrade handler with store additions for feemarket, precisebank, vm, erc20, and evmigration; post-migration finalization sets Lumera EVM params, feemarket params, and ERC20 defaults. - Added Action module precompile (`0x0901`) and Supernode module precompile (`0x0902`) giving Solidity contracts native access to `MsgRequestAction`/`MsgFinalizeAction` (including LEP-5 cascade availability commitments) and supernode queries/registration respectively. - Added CosmWasm ↔ EVM cross-runtime bridge (Phase 1, non-payable, depth-1 reentrancy guard): `WasmPrecompile` at `0x0903` exposes `execute`, `query`, `contractInfo`, `rawQuery` to Solidity, and a custom Wasm message handler + query handler decorator (`app/wasm_evm_plugin.go`) lets CosmWasm contracts invoke EVM contracts via `ApplyMessage` with an explicitly-constructed `statedb`. Cross-runtime gas is capped at `DefaultCrossRuntimeGasCap = 3,000,000` per call. diff --git a/app/evm/ante_evmigration_fee_test.go b/app/evm/ante_evmigration_fee_test.go index b335979b..cdb25b7b 100644 --- a/app/evm/ante_evmigration_fee_test.go +++ b/app/evm/ante_evmigration_fee_test.go @@ -80,7 +80,9 @@ func TestNewAnteHandlerMigrationOnlyCosmosTxUsesReducedAntePath(t *testing.T) { }) t.Run("migration-only unsigned zero-fee tx is accepted", func(t *testing.T) { - tx := newUnsignedMigrationTx(t, app, validMigrationMsg(t, anteMigrationTestChainID)) + msg := validMigrationMsg(t, anteMigrationTestChainID) + seedLegacyAccountInCtx(t, app, ctx, msg.LegacyAddress) + tx := newUnsignedMigrationTx(t, app, msg) _, err := anteHandler(ctx, tx, false) require.NoError(t, err) @@ -88,6 +90,10 @@ func TestNewAnteHandlerMigrationOnlyCosmosTxUsesReducedAntePath(t *testing.T) { t.Run("migration-only invalid embedded proof is rejected in ante", func(t *testing.T) { msg := validMigrationMsg(t, anteMigrationTestChainID) + // Seed the legacy account so the admission state-check passes and the + // corrupted proof is what actually triggers the rejection (the state + // check runs before proof verification). + seedLegacyAccountInCtx(t, app, ctx, msg.LegacyAddress) msg.LegacyProof.GetSingle().Signature[0] ^= 0x01 tx := newUnsignedMigrationTx(t, app, msg) @@ -116,6 +122,10 @@ func TestEVMigrationInvalidEmbeddedProofRejectedInCheckTx(t *testing.T) { app := lumeraapp.Setup(t) msg := validMigrationMsg(t, anteMigrationAppChainID) + // Seed the legacy account into the check-tx state so the admission + // state-check passes and the corrupted proof is what triggers rejection + // (the state check runs before proof verification). + seedLegacyAccountInCtx(t, app, app.BaseApp.NewContext(true), msg.LegacyAddress) msg.NewProof.GetSingle().Signature[0] ^= 0x01 tx := newUnsignedMigrationTx(t, app, msg) @@ -143,6 +153,20 @@ func newUnsignedMigrationTx(t *testing.T, app *lumeraapp.App, msgs ...sdk.Msg) s return txBuilder.GetTx() } +// seedLegacyAccountInCtx creates the legacy base account in the given ctx's +// state so the migration ante's legacy-account-exists admission gate +// (VerifyMigrationProofsForAnte) passes, letting a test exercise the proof / +// acceptance paths. NewAccountWithAddress assigns a fresh account number, +// avoiding the uniqueness conflict a bare base account (number 0) would hit +// against the genesis account. +func seedLegacyAccountInCtx(t *testing.T, app *lumeraapp.App, ctx sdk.Context, legacyAddress string) { + t.Helper() + + addr, err := sdk.AccAddressFromBech32(legacyAddress) + require.NoError(t, err) + app.AuthKeeper.SetAccount(ctx, app.AuthKeeper.NewAccountWithAddress(ctx, addr)) +} + // validMigrationMsg builds a MsgClaimLegacyAccount whose embedded proofs pass // ante-level cryptographic verification. func validMigrationMsg(t *testing.T, chainID string) *evmigrationtypes.MsgClaimLegacyAccount { diff --git a/app/evm_mempool_evmigration_test.go b/app/evm_mempool_evmigration_test.go index 450b696a..a03318ba 100644 --- a/app/evm_mempool_evmigration_test.go +++ b/app/evm_mempool_evmigration_test.go @@ -6,9 +6,11 @@ import ( "testing" abci "github.com/cometbft/cometbft/abci/types" + cmttypes "github.com/cometbft/cometbft/types" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" sdk "github.com/cosmos/cosmos-sdk/types" sdkmempool "github.com/cosmos/cosmos-sdk/types/mempool" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" evmcryptotypes "github.com/cosmos/evm/crypto/ethsecp256k1" "github.com/stretchr/testify/require" @@ -47,9 +49,10 @@ const testLegacyBech32 = "lumera1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc58av9gw" // This is a stronger test than calling app.GetMempool().Insert(...) directly // because it drives the same code path the live binary uses on broadcast. func TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx(t *testing.T) { - app := lumeraapp.Setup(t) + legacyPriv := secp256k1.GenPrivKey() + app := setupAppWithLegacyAccountForMempool(t, legacyPriv) - msg := validMigrationMsgForMempool(t, testChainID) + msg := validMigrationMsgForMempoolWithLegacy(t, testChainID, legacyPriv) tx := newUnsignedMigrationTxForMempool(t, app, msg) txBytes, err := app.TxConfig().TxEncoder()(tx) @@ -77,6 +80,27 @@ func TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx(t *testing.T) { resp.Code, resp.Log) } +func TestEVMMempool_CheckTxRejectsProofValidNonexistentLegacyAccount(t *testing.T) { + app := lumeraapp.Setup(t) + + msg := validMigrationMsgForMempool(t, testChainID) + tx := newUnsignedMigrationTxForMempool(t, app, msg) + + txBytes, err := app.TxConfig().TxEncoder()(tx) + require.NoError(t, err) + + resp, err := app.CheckTx(&abci.RequestCheckTx{ + Tx: txBytes, + Type: abci.CheckTxType_New, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotZero(t, resp.Code) + require.Contains(t, resp.Log, "legacy account not found", + "proof-valid migration txs from nonexistent legacy accounts must fail at ante admission") + require.NotContains(t, resp.Log, "at least one signer") +} + // TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx is the end-to-end // defense-in-depth pin: a zero-signer banktypes.MsgSend submitted through the // same CheckTx entry point an operator hits must still be rejected. @@ -261,9 +285,10 @@ func TestEVMMempool_DuplicateLegacyMigrationTxDoesNotGrowMempool(t *testing.T) { } func TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx(t *testing.T) { - app := lumeraapp.Setup(t) + legacyPriv := secp256k1.GenPrivKey() + app := setupAppWithLegacyAccountForMempool(t, legacyPriv) - msg := validMigrationMsgForMempool(t, testChainID) + msg := validMigrationMsgForMempoolWithLegacy(t, testChainID, legacyPriv) tx := newUnsignedMigrationTxForMempool(t, app, msg) txBytes, err := app.TxConfig().TxEncoder()(tx) require.NoError(t, err) @@ -281,8 +306,8 @@ func TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx(t *testing.T) { } // validMigrationMsgForMempool builds a MsgClaimLegacyAccount whose embedded -// proofs pass ante-level cryptographic verification, so the only thing that -// can reject the tx in CheckTx is the mempool's signer-extraction step. +// proofs pass ante-level cryptographic verification. Tests that expect CheckTx +// acceptance must also seed the legacy account so state admission passes. // // This mirrors validMigrationMsg in app/evm/ante_evmigration_fee_test.go but // lives here to avoid a cross-package test-only export. @@ -290,6 +315,16 @@ func validMigrationMsgForMempool(t *testing.T, chainID string) *evmigrationtypes t.Helper() legacyPriv := secp256k1.GenPrivKey() + return validMigrationMsgForMempoolWithLegacy(t, chainID, legacyPriv) +} + +func validMigrationMsgForMempoolWithLegacy( + t *testing.T, + chainID string, + legacyPriv *secp256k1.PrivKey, +) *evmigrationtypes.MsgClaimLegacyAccount { + t.Helper() + newPriv, err := evmcryptotypes.GenerateKey() require.NoError(t, err) @@ -335,3 +370,32 @@ func newUnsignedMigrationTxForMempool(t *testing.T, app *lumeraapp.App, msgs ... txBuilder.SetGasLimit(200_000) return txBuilder.GetTx() } + +func setupAppWithLegacyAccountForMempool(t *testing.T, legacyPriv *secp256k1.PrivKey) *lumeraapp.App { + t.Helper() + + privVal := cmttypes.NewMockPV() + pubKey, err := privVal.GetPubKey() + require.NoError(t, err) + + validator := cmttypes.NewValidator(pubKey, 1) + valSet := cmttypes.NewValidatorSet([]*cmttypes.Validator{validator}) + + legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address().Bytes()) + legacyAcc := authtypes.NewBaseAccount(legacyAddr, legacyPriv.PubKey(), 0, 0) + genBals := []banktypes.Balance{ + { + Address: legacyAddr.String(), + Coins: sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 100_000_000_000_000)), + }, + } + + return lumeraapp.SetupWithGenesisValSet( + t, + valSet, + []authtypes.GenesisAccount{legacyAcc}, + testChainID, + sdk.DefaultPowerReduction, + genBals, + ) +} diff --git a/docs/evm-integration/testing/bugs.md b/docs/evm-integration/testing/bugs.md index 547d6746..978753a2 100644 --- a/docs/evm-integration/testing/bugs.md +++ b/docs/evm-integration/testing/bugs.md @@ -381,6 +381,6 @@ Meanwhile, the EVM keeper (initialized in `x/vm/keeper/keeper.go:119`) correctly **Fix** (`app/evmigration_signer_extraction_adapter.go`, `app/evm_mempool.go`): Added an evmigration-aware signer extraction adapter beneath the EVM-aware default. For migration-only txs it derives a deterministic synthetic signer from the message `legacy_address`; all non-migration and mixed txs delegate to the normal fallback path. Multi-message migration txs are rejected so one tx cannot map to multiple synthetic signer buckets. -**Hardening — zero-fee mempool spam gate** (`x/evmigration/keeper/ante.go`): Admitting zero-signer migration txs to the mempool also opens a zero-fee spam vector — migration txs carry no fee and no signature, so anyone could flood the mempool/proposals with proof-valid txs that only fail at message execution. `VerifyMigrationProofsForAnte` now also enforces the migration admission window at the ante (rejects with `ErrMigrationDisabled` / `ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed), mirroring `preChecks` steps 1–2. This is a single param read (no per-account state) and is a no-op under default params (`EnableMigration=true`, `MigrationEndTime=0`); on mainnet the operator-set `MigrationEndTime` bounds the exposure to the migration window and closes it automatically. Message execution still re-checks against the canonical block time. +**Hardening — zero-fee mempool spam gate** (`x/evmigration/keeper/ante.go`): Admitting zero-signer migration txs to the mempool also opens a zero-fee spam vector — migration txs carry no fee and no signature, so anyone could flood the mempool/proposals with proof-valid txs that only fail at message execution. `VerifyMigrationProofsForAnte` now enforces the migration admission window at the ante (rejects with `ErrMigrationDisabled` / `ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed), mirroring `preChecks` steps 1–2. It also performs cheap state admission checks before mempool insertion: the legacy account must exist and must not be a module account, the source and destination must not already be migrated/claimed, and `MsgMigrateValidator` must name an existing source validator. The window check is a no-op under default params (`EnableMigration=true`, `MigrationEndTime=0`); on mainnet the operator-set `MigrationEndTime` bounds the exposure to the migration window and closes it automatically. Message execution still re-checks the full canonical migration rules. -**Tests**: `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx`, `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx`, `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress`, `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx`, `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx`, `TestVerifyMigrationProofsForAnte_AdmissionGate` (disabled / window-closed / open-window), and real-node `broadcast_tx_sync` coverage in `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled`, `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic`, and `TestZeroSignerNonMigrationBroadcastSyncStillRejected`. +**Tests**: `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx`, `TestEVMMempool_CheckTxRejectsProofValidNonexistentLegacyAccount`, `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx`, `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress`, `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx`, `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx`, `TestVerifyMigrationProofsForAnte_AdmissionGate` (disabled / window-closed / open-window), `TestVerifyMigrationProofsForAnte_CheapStateAdmission`, and real-node `broadcast_tx_sync` coverage in `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled`, `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte`, `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic`, and `TestZeroSignerNonMigrationBroadcastSyncStillRejected`. diff --git a/docs/evm-integration/testing/tests.md b/docs/evm-integration/testing/tests.md index 3deb679c..873c7c09 100644 --- a/docs/evm-integration/testing/tests.md +++ b/docs/evm-integration/testing/tests.md @@ -7,7 +7,7 @@ See [main.md](main.md) for architecture, app changes, and operational details. ## Executive Summary -Lumera ships **~560 EVM-related tests** spanning unit, integration, and devnet levels — the most comprehensive pre-mainnet EVM test suite in the Cosmos ecosystem. For context: +Lumera ships **~570 EVM-related tests** spanning unit, integration, and devnet levels — the most comprehensive pre-mainnet EVM test suite in the Cosmos ecosystem. For context: - **Evmos** — the first Cosmos EVM chain — launched mainnet with primarily unit tests and a handful of end-to-end scripts; their integration test suite was built incrementally *after* mainnet issues surfaced (e.g., the zero-base-fee spam incident). - **Kava** — relied heavily on simulation tests and manual QA for their EVM launch; structured integration tests came later. @@ -18,14 +18,14 @@ Lumera's suite goes beyond any of these baselines **before** mainnet: | Capability | Lumera | Typical Cosmos EVM chain at launch | | -------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------- | | Dual-route ante handler tests (EVM + Cosmos path) | 28 unit + 3 integration | Rarely tested separately | -| App-side mempool (ordering, nonce gaps, replacement, capacity, WS subscriptions, metrics, evmigration zero-signer admission) | 19 integration + 10 unit (metrics) | None (relies on CometBFT mempool) | +| App-side mempool (ordering, nonce gaps, replacement, capacity, WS subscriptions, metrics, evmigration zero-signer admission) | 20 integration + 10 unit (metrics) | None (relies on CometBFT mempool) | | Async broadcast queue (deadlock prevention) | 4 unit | Not applicable (novel to Lumera) | | JSON-RPC batching, persistence across restart | 23 integration | Basic RPC smoke tests | | ERC20/IBC middleware (v1 + v2 stacks) | 7 integration + 14 unit (policy) | Partial or post-launch | | Precisebank (6↔18 decimal bridge) | 39 unit + 6 integration | Not applicable (novel to Lumera) | | Feemarket (EIP-1559) | 9 unit + 8 integration | Inherited from upstream, rarely augmented | | Precompile coverage (11 precompiles + gas metering + action + supernode + wasm) | 42+ integration | Smoke-level | -| Account migration (coin-type 118→60) | 117+ keeper/CLI unit + app-level mempool regression tests + 18 integration + devnet tool | Not applicable (novel to Lumera) | +| Account migration (coin-type 118→60) | 150+ keeper/CLI unit + app-level mempool regression tests + 18 integration + devnet tool | Not applicable (novel to Lumera) | | OpenRPC discovery + spec sync | 15 unit + 2 integration | No chain has this | | WebSocket subscriptions (newHeads, logs, pending) | 4 integration | Untested or manual | | Cross-runtime bridge (CosmWasm ↔ EVM) | 12 integration + 31 unit + 15 crossruntime unit | No chain has this | @@ -51,7 +51,7 @@ All three previously identified critical test gaps (mempool capacity pressure, b | **Unit** | OpenRPC / generator | 15 | High — [details](tests/unit-openrpc.md) | | **Unit** | JSON-RPC rate limiting | 25 | High — right-to-left XFF parsing, trusted-hop skipping, CIDR parsing | | **Unit** | ERC20 policy | 14 | High — 3 modes, base denom + exact ibc/ allowlist CRUD | -| **Unit** | EVMigration keeper | 117+ | Excellent — [details](tests/unit-evmigration.md) | +| **Unit** | EVMigration keeper | 124+ | Excellent — [details](tests/unit-evmigration.md) | | **Unit** | EVMigration types (proof) | 6 | High — `TestMultisigProof_ValidateBasic`, `TestMultisigProof_ValidateParams_SizeCap`, `TestLegacyProof_ValidateBasic_Dispatch`, `TestSingleKeyProof_ValidateBasic` and variants | | **Unit** | EVMigration CLI | 26 | High — [details](tests/unit-evmigration-cli.md) | | **Unit** | Cross-runtime bridge (plugin helpers + crossruntime) | 46 | High — [details](tests/integration-precompiles.md#cosmwasm---evm-plugin-unit-tests) | @@ -61,16 +61,16 @@ All three previously identified critical test gaps (mempool capacity pressure, b | **Integration** | Fee market | 8 | Excellent — [details](tests/integration-feemarket.md) | | **Integration** | IBC ERC20 | 7 | High — [details](tests/integration-ibc-erc20.md) | | **Integration** | JSON-RPC / indexer | 23 | Very high — [details](tests/integration-jsonrpc.md) | -| **Integration** | Mempool | 19 | High — [details](tests/integration-mempool.md) | +| **Integration** | Mempool | 20 | High — [details](tests/integration-mempool.md) | | **Integration** | Precisebank | 6 | High — [details](tests/integration-precisebank.md) | | **Integration** | Precompiles (standard + custom + wasm) | 42 | High — [details](tests/integration-precompiles.md) | | **Integration** | VM queries / state | 12 | High — [details](tests/integration-vm.md) | -| **Integration** | EVMigration | 15+ core + 3 mempool broadcast regressions | High — [details](tests/integration-evmigration.md) | +| **Integration** | EVMigration | 14 core + 4 mempool broadcast regressions | High — [details](tests/integration-evmigration.md) | | | | | | | **Devnet** | EVM / fee market / cross-peer / IBC | 12+ | High — [details](tests/devnet.md) | | **Devnet** | EVMigration tool | 7 modes | High — [details](tests/devnet.md#evm-migration-devnet-tests) | | | | | | -| | **Totals** | **Unit: ~399 · Integration: ~150 · Devnet: 12+ · Total: ~561** | | +| | **Totals** | **Unit: ~407 · Integration: ~150 · Devnet: 12+ · Total: ~569** | | ### Gaps and next steps @@ -115,7 +115,7 @@ Each area has its own detailed file with per-test descriptions: | Fee market (EIP-1559) | [unit-feemarket.md](tests/unit-feemarket.md) | 9 | | Precisebank (6↔18 bridge) | [unit-precisebank.md](tests/unit-precisebank.md) | 39 | | OpenRPC & generator | [unit-openrpc.md](tests/unit-openrpc.md) | 15 | -| EVMigration keeper | [unit-evmigration.md](tests/unit-evmigration.md) | 117+ | +| EVMigration keeper | [unit-evmigration.md](tests/unit-evmigration.md) | 124+ | | EVMigration types (proof) | `x/evmigration/types/proof_test.go` | 6 | | EVMigration CLI | [unit-evmigration-cli.md](tests/unit-evmigration-cli.md) | 26 | @@ -128,11 +128,11 @@ Each area has its own detailed file with per-test descriptions: | Fee market (EIP-1559) | [integration-feemarket.md](tests/integration-feemarket.md) | 8 | | IBC ERC20 middleware | [integration-ibc-erc20.md](tests/integration-ibc-erc20.md) | 7 | | JSON-RPC & indexer | [integration-jsonrpc.md](tests/integration-jsonrpc.md) | 23 | -| Mempool | [integration-mempool.md](tests/integration-mempool.md) | 19 | +| Mempool | [integration-mempool.md](tests/integration-mempool.md) | 20 | | Precisebank | [integration-precisebank.md](tests/integration-precisebank.md) | 6 | | Precompiles (standard + custom + wasm + crossruntime) | [integration-precompiles.md](tests/integration-precompiles.md) | 42 | | VM queries / state | [integration-vm.md](tests/integration-vm.md) | 12 | -| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 15+ core + 3 mempool broadcast regressions | +| EVMigration | [integration-evmigration.md](tests/integration-evmigration.md) | 14 core + 4 mempool broadcast regressions | ### Devnet Tests diff --git a/docs/evm-integration/testing/tests/integration-evmigration.md b/docs/evm-integration/testing/tests/integration-evmigration.md index b0bf1ebf..278cffd6 100644 --- a/docs/evm-integration/testing/tests/integration-evmigration.md +++ b/docs/evm-integration/testing/tests/integration-evmigration.md @@ -24,5 +24,6 @@ Additional real-node broadcast coverage for zero-signer `submit-proof` txs lives | `TestQueryMigrationRecord_Integration` | Query server returns record after real migration, nil before. | | `TestQueryMigrationEstimate_Integration` | Estimate query with real staking state reports correct values. | | `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Mempool-suite regression: valid zero-signer migration tx passes real-node CheckTx with app-side mempool enabled. | +| `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte` | Mempool-suite negative test: proof-valid zero-signer migration tx is rejected by ante state admission when the legacy account does not exist. | | `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic` | Mempool-suite negative test: malformed `legacy_address` is rejected by `ValidateBasic` in the ante chain on the real-node broadcast path (before mempool admission). | | `TestZeroSignerNonMigrationBroadcastSyncStillRejected` | Mempool-suite negative control: zero-signer non-migration tx remains rejected. | diff --git a/docs/evm-integration/testing/tests/integration-mempool.md b/docs/evm-integration/testing/tests/integration-mempool.md index ec4c7690..5e1d039e 100644 --- a/docs/evm-integration/testing/tests/integration-mempool.md +++ b/docs/evm-integration/testing/tests/integration-mempool.md @@ -26,5 +26,6 @@ Suites: | `TestPrometheusMetricsExposeMempoolGauges` | E2E: starts node with Prometheus telemetry, scrapes /metrics, verifies gauges. | | `TestPrometheusRejectionsCountedViaCometCheckTx` | E2E: submits malformed bytes via CometBFT broadcast_tx_sync, verifies rejection counter. | | `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled` | Real-node `broadcast_tx_sync`: a valid zero-signer `MsgClaimLegacyAccount` passes CheckTx with the app-side EVM mempool enabled. | +| `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte` | Real-node `broadcast_tx_sync`: a proof-valid zero-signer migration tx is rejected by ante state admission when the legacy account does not exist. | | `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic` | Real-node `broadcast_tx_sync`: malformed migration `legacy_address` is rejected by `ValidateBasic` in the ante chain, before mempool admission. | | `TestZeroSignerNonMigrationBroadcastSyncStillRejected` | Negative control: a zero-signer non-migration tx is still rejected, proving the evmigration adapter does not widen signer bypass behavior. | diff --git a/docs/evm-integration/testing/tests/unit-evmigration.md b/docs/evm-integration/testing/tests/unit-evmigration.md index ee2a8ae0..42665232 100644 --- a/docs/evm-integration/testing/tests/unit-evmigration.md +++ b/docs/evm-integration/testing/tests/unit-evmigration.md @@ -2,29 +2,23 @@ Purpose: validates the `x/evmigration` module — dual-signature verification, account/bank/staking/distribution/authz/feegrant/supernode/action/claim migration, preChecks, and full ClaimLegacyAccount message handler flow. -Files: `x/evmigration/keeper/verify_test.go`, `x/evmigration/keeper/migrate_test.go`, `x/evmigration/keeper/msg_server_claim_legacy_test.go`, `x/evmigration/keeper/msg_server_migrate_validator_test.go`, `x/evmigration/keeper/query_test.go` +Files: `x/evmigration/types/sigverify/sigverify_test.go`, `x/evmigration/keeper/verify_test.go`, `x/evmigration/keeper/migrate_test.go`, `x/evmigration/keeper/msg_server_claim_legacy_test.go`, `x/evmigration/keeper/msg_server_migrate_validator_test.go`, `x/evmigration/keeper/query_test.go` | Test | Description | | --- | --- | -| `TestVerifyLegacySignature_Valid` | Verifies a correctly signed migration message passes verification. | -| `TestVerifyLegacySignature_InvalidPubKeySize` | Rejects public keys that are not exactly 33 bytes (compressed secp256k1). | -| `TestVerifyLegacySignature_PubKeyAddressMismatch` | Rejects when the public key does not derive to the claimed legacy address. | -| `TestVerifyLegacySignature_InvalidSignature` | Rejects a signature produced by a different private key. | -| `TestVerifyLegacySignature_WrongMessage` | Rejects a valid signature produced over a different new address. | -| `TestVerifyLegacySignature_EmptySignature` | Rejects a nil/empty signature. | -| `TestVerifyNewSignature_EIP191` | Verifies EIP-191 personal_sign signature (Keplr/Leap wallet path) passes new key verification. | -| `TestVerifyNewSignature_EIP191_Validator` | Verifies EIP-191 path works for the "validator" migration kind. | -| `TestVerifyNewSignature_EIP191_WrongKey` | Rejects an EIP-191 signature from the wrong private key. | -| `TestVerifyLegacySignature_ADR036` | Verifies ADR-036 signArbitrary signature (Keplr/Leap wallet path) passes legacy key verification. | -| `TestVerifyLegacySignature_ADR036_Validator` | Verifies ADR-036 path works for the "validator" migration kind. | -| `TestVerifyLegacySignature_ADR036_WrongKey` | Rejects an ADR-036 signature from the wrong private key. | -| `TestVerifyLegacySignature_ADR036_WrongSigner` | Rejects ADR-036 signature with mismatched signer field in the sign doc. | -| `TestVerifyLegacySignature_ADR036_DocFormat` | Verifies canonical ADR-036 JSON structure matches expected format byte-for-byte. | -| `TestVerifyNewSignature_EIP191_PayloadFormat` | Verifies EIP-191 prefix construction is correct for a known payload. | -| `TestVerifyLegacySignature_BothPathsRejectGarbage` | Verifies neither raw nor ADR-036 path accepts a garbage signature. | -| `TestVerifyNewSignature_BothPathsRejectGarbage` | Verifies neither raw nor EIP-191 path accepts a garbage signature. | -| `TestVerifyLegacySignature_ChainIDMismatch` | Signs legacy proof with wrong chain ID, verifies error includes the expected chain ID to help diagnose mismatches. | -| `TestVerifyNewSignature_ChainIDMismatch` | Signs new proof with wrong chain ID, verifies address-mismatch error includes chain ID hint. | +| `TestVerifyCosmosSecp256k1_CLI` | Legacy-side cosmos secp256k1: a CLI-format (SHA256) signature verifies. | +| `TestVerifyCosmosSecp256k1_ADR036` | Legacy-side cosmos secp256k1: an ADR-036 signArbitrary signature (Keplr/Leap path) verifies. | +| `TestVerifyCosmosSecp256k1_EIP191_Rejected` | Legacy-side cosmos secp256k1: EIP-191 format is rejected (wrong side for that format). | +| `TestVerifyCosmosSecp256k1_InvalidSigFormat` | Legacy-side cosmos secp256k1: `SIG_FORMAT_UNSPECIFIED` (default switch branch) is rejected with a clear error. | +| `TestVerifyCosmosSecp256k1_WrongKey` | Legacy-side cosmos secp256k1: a valid-format signature does not verify under a different pubkey (CLI and ADR-036). | +| `TestVerifyEthSecp256k1_CLI_65byte` | New-side eth secp256k1: a 65-byte (R\|\|S\|\|V) CLI signature verifies. | +| `TestVerifyEthSecp256k1_ADR036_65byte` | New-side eth secp256k1: a 65-byte ADR-036 signature verifies. | +| `TestVerifyEthSecp256k1_EIP191_65byte` | New-side eth secp256k1: a 65-byte EIP-191 personal_sign signature verifies. | +| `TestVerifyEthSecp256k1_VByteIgnoredByVerifier` | New-side eth secp256k1: clobbering the recovery V byte does not invalidate an otherwise-valid R\|\|S signature (verifier uses R\|\|S only). | +| `TestVerifyEthSecp256k1_Reject64Byte` | New-side eth secp256k1: a 64-byte signature (R\|\|S, no V) is rejected. | +| `TestVerifyEthSecp256k1_RejectOtherLengths` | New-side eth secp256k1: signatures of any length other than 65 bytes are rejected. | +| `TestVerifyEthSecp256k1_InvalidSigFormat` | New-side eth secp256k1: `SIG_FORMAT_UNSPECIFIED` is rejected. | +| `TestVerifyEthSecp256k1_WrongKey` | New-side eth secp256k1: a valid 65-byte signature does not verify under a different pubkey. | | `TestMigrateAuth_BaseAccount` | Verifies BaseAccount removal and new account creation. | | `TestMigrateAuth_ContinuousVesting` | Verifies ContinuousVestingAccount parameters are captured in VestingInfo. | | `TestMigrateAuth_DelayedVesting` | Verifies DelayedVestingAccount parameters are captured in VestingInfo. | @@ -142,6 +136,7 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | `TestEVMigrationSignerAdapter_DefaultExtractor_PinsFailureMode` | Pins the upstream SDK default extractor behavior: zero-signer migration txs produce no signers. | | `TestEVMMempool_SDKPriorityNonceMempoolRejectsZeroSignerMigrationTx` | Demonstrates the raw SDK `PriorityNonceMempool` rejection that the app adapter fixes. | | `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx` | Full app CheckTx path accepts a valid zero-signer migration tx. | +| `TestEVMMempool_CheckTxRejectsProofValidNonexistentLegacyAccount` | Full app CheckTx path rejects a proof-valid zero-signer migration tx when the legacy account is absent from state, before falling back to the generic signer error. | | `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx` | End-to-end pin: zero-signer non-migration txs are rejected on the live CheckTx path (by the ante's signature verification, before mempool admission). | | `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx` | Adapter-layer security pin: drives `mempool.Insert` directly (bypassing the ante) to prove a non-migration tx gets no synthetic signer and is rejected with "tx must have at least one signer". | | `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx` | App mempool accepts zero-signer `MsgMigrateValidator`. | @@ -150,6 +145,7 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | `TestEVMMempool_DuplicateLegacyMigrationTxDoesNotGrowMempool` | Duplicate txs for the same synthetic legacy-address signer do not grow the mempool. | | `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx` | Accepted zero-signer migration txs are selected by `PrepareProposal`. | | `TestVerifyMigrationProofsForAnte_AdmissionGate` | Admission gate: proof-valid migration txs are rejected at the ante (`ErrMigrationDisabled` / `ErrMigrationWindowClosed`) when migration is off or the window has closed, bounding the zero-fee mempool-spam surface to the operator-defined window. | +| `TestVerifyMigrationProofsForAnte_CheapStateAdmission` | Cheap state gate: proof-valid migration txs are rejected at ante admission when the legacy account is missing, the source is already migrated, the destination is already used, or a validator migration source is not a validator. | ## Multisig support tests @@ -157,12 +153,9 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | Test | Description | | ---- | ----------- | -| `TestVerifyLegacyProof_Multisig_ValidCLI` | 2-of-3 multisig with CLI sig format passes verifier. | -| `TestVerifyLegacyProof_Multisig_ValidADR036` | 2-of-3 multisig with ADR-036 sig format passes verifier. | -| `TestVerifyLegacyProof_Multisig_1of1` | 1-of-1 multisig (degenerate edge case) passes verifier. | -| `TestVerifyLegacyProof_Multisig_WrongAddress` | Proof whose recovered address does not match `legacy_address` is rejected. | -| `TestVerifyLegacyProof_Multisig_InvalidSubSig` | One corrupted sub-signature causes rejection. | -| `TestVerifyLegacyProof_Multisig_N20Boundary` | N=20 (at `MaxMultisigSubKeys`) passes; N=21 is rejected by `ValidateParams`. | +| `TestVerifyMigrationProof_NewSide_Multisig_Valid2of3` | New-side 2-of-3 multisig (sub-signers 0 and 2, CLI format) passes the proof verifier. | +| `TestVerifyMigrationProof_NewSide_Multisig_SubSigInvalid_UnderCosmosKeyBytes` | New-side multisig is rejected when a sub-signature is a SHA256-convention Cosmos signature padded to 65 bytes: the outer bound-address check passes but `VerifyEthSecp256k1`'s R\|\|S verify fails. | +| `TestVerifyMigrationProof_NewSide_Multisig_AminoAddressMismatch_OnKeyTypeSwap` | New-side multisig is rejected when the bound address was built under the Cosmos interpretation but the verifier wraps the sub-keys as eth secp256k1 (key-type swap → amino address mismatch). | ### Multisig query tests (`x/evmigration/keeper/query_test.go`) @@ -171,7 +164,7 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | `TestLegacyAccounts_Multisig` | `LegacyAccounts` response includes `is_multisig=true`, correct `threshold` and `num_signers`. | | `TestMigrationEstimate_Multisig_Supported` | Estimate returns `would_succeed=true` for a valid K-of-N secp256k1 multisig. | | `TestMigrationEstimate_Multisig_TooManySubKeys` | Estimate returns `would_succeed=false` when `num_signers > MaxMultisigSubKeys`. | -| `TestMigrationEstimate_Multisig_NonSecp256k1` | Estimate returns `would_succeed=false` when any sub-key is not secp256k1. | +| `TestMigrationEstimate_Multisig_NonSecp256k1SubKey` | Estimate returns `would_succeed=false` when any sub-key is not secp256k1. | ### Type validation tests (`x/evmigration/types/proof_test.go`) @@ -180,4 +173,4 @@ Files: `app/evmigration_signer_extraction_adapter_test.go`, `app/evm_mempool_evm | `TestSingleKeyProof_ValidateBasic` | Valid and invalid `SingleKeyProof` shapes (nil pub_key, nil sig, unspecified format). | | `TestMultisigProof_ValidateBasic` | Valid and invalid `MultisigProof` shapes (zero threshold, mismatched indices/sigs length, non-ascending indices, wrong sub-key size, unspecified format). | | `TestMultisigProof_ValidateParams_SizeCap` | `ValidateParams` rejects when `len(sub_pub_keys) > MaxMultisigSubKeys`. | -| `TestLegacyProof_ValidateBasic_Dispatch` | `LegacyProof.ValidateBasic` dispatches to the correct sub-validator and rejects a nil oneof. | +| `TestMigrationProof_ValidateBasic_Dispatch` | `MigrationProof.ValidateBasic` dispatches to the correct sub-validator and rejects a nil oneof. | diff --git a/tests/integration/evm/mempool/evmigration_zero_signer_test.go b/tests/integration/evm/mempool/evmigration_zero_signer_test.go index 1a502de3..327ee876 100644 --- a/tests/integration/evm/mempool/evmigration_zero_signer_test.go +++ b/tests/integration/evm/mempool/evmigration_zero_signer_test.go @@ -6,7 +6,10 @@ package mempool_test import ( "context" "crypto/sha256" + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" "time" @@ -16,6 +19,7 @@ import ( cmttypes "github.com/cometbft/cometbft/types" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" evmcryptotypes "github.com/cosmos/evm/crypto/ethsecp256k1" "github.com/stretchr/testify/require" @@ -28,17 +32,34 @@ import ( func TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled(t *testing.T) { node := evmtest.NewEVMNode(t, "lumera-evmigration-mempool", 20) + legacyPriv := secp256k1.GenPrivKey() + addGenesisLegacyAccount(t, node, sdk.AccAddress(legacyPriv.PubKey().Address().Bytes())) node.StartAndWaitRPC() defer node.Stop() node.WaitForBlockNumberAtLeast(t, 1, 20*time.Second) - txBytes := validZeroSignerMigrationTxBytes(t, node.ChainID()) + txBytes := validZeroSignerMigrationTxBytes(t, node.ChainID(), legacyPriv) res := broadcastSync(t, node, txBytes) require.Zero(t, res.Code, "zero-signer migration tx must pass CheckTx with app-side mempool enabled: %s", res.Log) require.NotContains(t, res.Log, "tx must have at least one signer") } +func TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte(t *testing.T) { + node := evmtest.NewEVMNode(t, "lumera-evmigration-no-legacy", 20) + node.StartAndWaitRPC() + defer node.Stop() + node.WaitForBlockNumberAtLeast(t, 1, 20*time.Second) + + txBytes := validZeroSignerMigrationTxBytes(t, node.ChainID(), secp256k1.GenPrivKey()) + res := broadcastSync(t, node, txBytes) + + require.NotZero(t, res.Code) + require.Contains(t, res.Log, "legacy account not found", + "proof-valid migration txs from nonexistent legacy accounts must fail before mempool admission") + require.NotContains(t, res.Log, "at least one signer") +} + // TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic confirms that a // migration tx carrying a non-bech32 legacy_address is rejected end-to-end on a // real node. @@ -102,10 +123,9 @@ func TestZeroSignerNonMigrationBroadcastSyncStillRejected(t *testing.T) { ) } -func validZeroSignerMigrationTxBytes(t *testing.T, chainID string) []byte { +func validZeroSignerMigrationTxBytes(t *testing.T, chainID string, legacyPriv *secp256k1.PrivKey) []byte { t.Helper() - legacyPriv := secp256k1.GenPrivKey() newPriv, err := evmcryptotypes.GenerateKey() require.NoError(t, err) @@ -144,6 +164,45 @@ func validZeroSignerMigrationTxBytes(t *testing.T, chainID string) []byte { return unsignedTxBytes(t, msg) } +func addGenesisLegacyAccount(t *testing.T, node *evmtest.Node, legacyAddr sdk.AccAddress) { + t.Helper() + + encCfg := lumeraapp.MakeEncodingConfig(t) + genesisPath := filepath.Join(node.HomeDir(), "config", "genesis.json") + genesisBytes, err := os.ReadFile(genesisPath) + require.NoError(t, err) + + var genesisDoc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(genesisBytes, &genesisDoc)) + + var appState map[string]json.RawMessage + require.NoError(t, json.Unmarshal(genesisDoc["app_state"], &appState)) + + authGenesis := authtypes.GetGenesisStateFromAppState(encCfg.Codec, appState) + accounts, err := authtypes.UnpackAccounts(authGenesis.Accounts) + require.NoError(t, err) + accounts = append(accounts, authtypes.NewBaseAccount(legacyAddr, nil, uint64(len(accounts)), 0)) + authGenesis.Accounts, err = authtypes.PackAccounts(accounts) + require.NoError(t, err) + appState[authtypes.ModuleName] = encCfg.Codec.MustMarshalJSON(&authGenesis) + + bankGenesis := banktypes.GetGenesisStateFromAppState(encCfg.Codec, appState) + coins := sdk.NewCoins(sdk.NewInt64Coin(lcfg.ChainDenom, 1_000_000)) + bankGenesis.Balances = append(bankGenesis.Balances, banktypes.Balance{ + Address: legacyAddr.String(), + Coins: coins, + }) + bankGenesis.Supply = bankGenesis.Supply.Add(coins...) + appState[banktypes.ModuleName] = encCfg.Codec.MustMarshalJSON(bankGenesis) + + genesisDoc["app_state"], err = json.Marshal(appState) + require.NoError(t, err) + + updated, err := json.MarshalIndent(genesisDoc, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(genesisPath, updated, 0o644)) +} + func unsignedTxBytes(t *testing.T, msgs ...sdk.Msg) []byte { t.Helper() diff --git a/x/evmigration/keeper/ante.go b/x/evmigration/keeper/ante.go index d87ce3cb..050dd9f3 100644 --- a/x/evmigration/keeper/ante.go +++ b/x/evmigration/keeper/ante.go @@ -4,7 +4,9 @@ import ( "fmt" "time" + errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" lcfg "github.com/LumeraProtocol/lumera/config" "github.com/LumeraProtocol/lumera/x/evmigration/types" @@ -73,6 +75,9 @@ func (k Keeper) VerifyMigrationProofsForAnte(ctx sdk.Context, msg sdk.Msg) error if params.MigrationEndTime > 0 && ctx.BlockTime().After(time.Unix(params.MigrationEndTime, 0)) { return types.ErrMigrationWindowClosed } + if err := k.verifyMigrationAdmissionState(ctx, msg, legacyAddr, newAddr); err != nil { + return err + } if err := legacyProof.ValidateParams(params.MaxMultisigSubKeys); err != nil { return err @@ -96,3 +101,55 @@ func (k Keeper) VerifyMigrationProofsForAnte(ctx sdk.Context, msg sdk.Msg) error newProof, sigverify.SubKeyTypeEthSecp256k1, ) } + +func (k Keeper) verifyMigrationAdmissionState(ctx sdk.Context, msg sdk.Msg, legacyAddr, newAddr sdk.AccAddress) error { + if legacyAddr.Equals(newAddr) { + return types.ErrSameAddress + } + + has, err := k.MigrationRecords.Has(ctx, legacyAddr.String()) + if err != nil { + return err + } + if has { + return types.ErrAlreadyMigrated + } + + has, err = k.MigrationRecords.Has(ctx, newAddr.String()) + if err != nil { + return err + } + if has { + return types.ErrNewAddressWasMigrated + } + + has, err = k.MigrationRecordByNewAddress.Has(ctx, newAddr.String()) + if err != nil { + return err + } + if has { + return types.ErrNewAddressAlreadyUsed + } + + legacyAcc := k.accountKeeper.GetAccount(ctx, legacyAddr) + if legacyAcc == nil { + return types.ErrLegacyAccountNotFound + } + if _, ok := legacyAcc.(sdk.ModuleAccountI); ok { + return types.ErrCannotMigrateModuleAccount + } + + if _, ok := msg.(*types.MsgMigrateValidator); !ok { + return nil + } + + _, err = k.stakingKeeper.GetValidator(ctx, sdk.ValAddress(legacyAddr)) + switch { + case err == nil: + return nil + case errorsmod.IsOf(err, stakingtypes.ErrNoValidatorFound): + return types.ErrNotValidator + default: + return fmt.Errorf("lookup source validator: %w", err) + } +} diff --git a/x/evmigration/keeper/ante_test.go b/x/evmigration/keeper/ante_test.go index 023eb46b..0514b7bc 100644 --- a/x/evmigration/keeper/ante_test.go +++ b/x/evmigration/keeper/ante_test.go @@ -7,25 +7,36 @@ import ( "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" sdk "github.com/cosmos/cosmos-sdk/types" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" + stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/LumeraProtocol/lumera/x/evmigration/types" ) func TestVerifyMigrationProofsForAnte(t *testing.T) { - fixture := initMsgServerFixture(t) - legacyPriv := secp256k1.GenPrivKey() legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address()) newPriv, newAddr := testNewMigrationAccount(t) t.Run("claim valid proofs", func(t *testing.T) { + fixture := initMsgServerFixture(t) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) require.NoError(t, fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg)) }) t.Run("claim invalid legacy proof", func(t *testing.T) { + fixture := initMsgServerFixture(t) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) msg.LegacyProof.GetSingle().Signature[0] ^= 0x01 @@ -35,6 +46,11 @@ func TestVerifyMigrationProofsForAnte(t *testing.T) { }) t.Run("claim invalid new proof", func(t *testing.T) { + fixture := initMsgServerFixture(t) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) msg.NewProof.GetSingle().Signature[0] ^= 0x01 @@ -44,11 +60,21 @@ func TestVerifyMigrationProofsForAnte(t *testing.T) { }) t.Run("validator valid proofs", func(t *testing.T) { + fixture := initMsgServerFixture(t) + oldValAddr := sdk.ValAddress(legacyAddr) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) + fixture.stakingKeeper.EXPECT(). + GetValidator(gomock.Any(), oldValAddr). + Return(stakingtypes.Validator{OperatorAddress: oldValAddr.String()}, nil) + msg := newValidatorMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) require.NoError(t, fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg)) }) t.Run("unsupported message type", func(t *testing.T) { + fixture := initMsgServerFixture(t) msg := banktypes.NewMsgSend(legacyAddr, newAddr, sdk.NewCoins()) err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) @@ -61,9 +87,9 @@ func TestVerifyMigrationProofsForAnte(t *testing.T) { // gate: when migration is disabled or the window has closed, a proof-valid // migration tx must be rejected at the ante — before mempool insertion — so // zero-fee migration txs cannot flood the mempool outside the operator-defined -// window. This is the cheap defense (a single param read, no per-account state) -// against the zero-fee spam vector opened by admitting zero-signer migration -// txs (PR #167). +// window. This is one cheap defense against the zero-fee spam vector opened by +// admitting zero-signer migration txs (PR #167); per-account plausibility checks +// are pinned separately in TestVerifyMigrationProofsForAnte_CheapStateAdmission. func TestVerifyMigrationProofsForAnte_AdmissionGate(t *testing.T) { legacyPriv := secp256k1.GenPrivKey() legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address()) @@ -96,8 +122,65 @@ func TestVerifyMigrationProofsForAnte_AdmissionGate(t *testing.T) { // valid proofs accepted. require.NoError(t, fixture.keeper.Params.Set(fixture.ctx, types.NewParams(true, 5000, 50, 2000, 20))) ctx := fixture.ctx.WithBlockTime(time.Unix(1000, 0)) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) require.NoError(t, fixture.keeper.VerifyMigrationProofsForAnte(ctx, msg)) }) } + +func TestVerifyMigrationProofsForAnte_CheapStateAdmission(t *testing.T) { + legacyPriv := secp256k1.GenPrivKey() + legacyAddr := sdk.AccAddress(legacyPriv.PubKey().Address()) + newPriv, newAddr := testNewMigrationAccount(t) + + t.Run("rejects nonexistent legacy account before mempool admission", func(t *testing.T) { + fixture := initMsgServerFixture(t) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(nil) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) + require.ErrorIs(t, err, types.ErrLegacyAccountNotFound) + }) + + t.Run("rejects already migrated legacy account", func(t *testing.T) { + fixture := initMsgServerFixture(t) + require.NoError(t, fixture.keeper.MigrationRecords.Set(fixture.ctx, legacyAddr.String(), types.MigrationRecord{ + LegacyAddress: legacyAddr.String(), + NewAddress: newAddr.String(), + })) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) + require.ErrorIs(t, err, types.ErrAlreadyMigrated) + }) + + t.Run("rejects reused migration destination", func(t *testing.T) { + fixture := initMsgServerFixture(t) + otherLegacy := sdk.AccAddress(secp256k1.GenPrivKey().PubKey().Address()) + require.NoError(t, fixture.keeper.MigrationRecordByNewAddress.Set(fixture.ctx, newAddr.String(), otherLegacy.String())) + + msg := newClaimMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) + require.ErrorIs(t, err, types.ErrNewAddressAlreadyUsed) + }) + + t.Run("rejects validator migration for non-validator source", func(t *testing.T) { + fixture := initMsgServerFixture(t) + oldValAddr := sdk.ValAddress(legacyAddr) + fixture.accountKeeper.EXPECT(). + GetAccount(gomock.Any(), legacyAddr). + Return(authtypes.NewBaseAccountWithAddress(legacyAddr)) + fixture.stakingKeeper.EXPECT(). + GetValidator(gomock.Any(), oldValAddr). + Return(stakingtypes.Validator{}, stakingtypes.ErrNoValidatorFound) + + msg := newValidatorMigrationMsg(t, legacyPriv, legacyAddr, newPriv, newAddr) + err := fixture.keeper.VerifyMigrationProofsForAnte(fixture.ctx, msg) + require.ErrorIs(t, err, types.ErrNotValidator) + }) +}