diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e69f5cc..0087c17a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +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" 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`, 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.go b/app/evm_mempool.go index 08695df7..89a64217 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,21 @@ 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" during mempool admission and proposal selection. + // + // 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 +60,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 +109,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 +138,56 @@ 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 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() { + 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..a03318ba --- /dev/null +++ b/app/evm_mempool_evmigration_test.go @@ -0,0 +1,401 @@ +package app_test + +import ( + "crypto/sha256" + "fmt" + "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" + + lumeraapp "github.com/LumeraProtocol/lumera/app" + lcfg "github.com/LumeraProtocol/lumera/config" + evmigrationtypes "github.com/LumeraProtocol/lumera/x/evmigration/types" +) + +// 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. +// +// Real flow on a v1.20.0 mainnet binary: an operator runs +// +// 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 succeeds. +// +// 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) { + legacyPriv := secp256k1.GenPrivKey() + app := setupAppWithLegacyAccountForMempool(t, legacyPriv) + + msg := validMigrationMsgForMempoolWithLegacy(t, testChainID, legacyPriv) + 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) + + // 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) +} + +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. +// +// 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. +// +// 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) + + 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 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 +// 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'") +} + +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) { + legacyPriv := secp256k1.GenPrivKey() + app := setupAppWithLegacyAccountForMempool(t, legacyPriv) + + msg := validMigrationMsgForMempoolWithLegacy(t, testChainID, legacyPriv) + 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. 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. +func validMigrationMsgForMempool(t *testing.T, chainID string) *evmigrationtypes.MsgClaimLegacyAccount { + 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) + + 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) + + 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() +} + +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/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 new file mode 100644 index 00000000..3fd75689 --- /dev/null +++ b/app/evmigration_signer_extraction_adapter.go @@ -0,0 +1,101 @@ +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 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 +// 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) + } + if len(msgs) != 1 { + return nil, fmt.Errorf("evmigration tx must contain exactly one migration message for mempool signer derivation, got %d", len(msgs)) + } + + // 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 + } + 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..c0f20a52 --- /dev/null +++ b/app/evmigration_signer_extraction_adapter_test.go @@ -0,0 +1,180 @@ +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_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) + + 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/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..978753a2 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 enforces the migration admission window at the ante (rejects with `ErrMigrationDisabled` / `ErrMigrationWindowClosed` when `EnableMigration` is off or `MigrationEndTime` has passed), mirroring `preChecks` steps 1–2. It also performs cheap state admission checks before mempool insertion: the legacy account must exist and must not be a module account, the source and destination must not already be migrated/claimed, and `MsgMigrateValidator` must name an existing source validator. The window check is a no-op under default params (`EnableMigration=true`, `MigrationEndTime=0`); on mainnet the operator-set `MigrationEndTime` bounds the exposure to the migration window and closes it automatically. Message execution still re-checks the full canonical migration rules. + +**Tests**: `TestEVMMempool_CheckTxAcceptsZeroSignerMigrationTx`, `TestEVMMempool_CheckTxRejectsProofValidNonexistentLegacyAccount`, `TestEVMMempool_CheckTxRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertRejectsZeroSignerNonMigrationTx`, `TestEVMMempool_InsertAcceptsZeroSignerValidatorMigrationTx`, `TestEVMMempool_InsertRejectsMalformedMigrationLegacyAddress`, `TestEVMMempool_InsertRejectsZeroSignerMixedMigrationTx`, `TestEVMMempool_PrepareProposalIncludesZeroSignerMigrationTx`, `TestVerifyMigrationProofsForAnte_AdmissionGate` (disabled / window-closed / open-window), `TestVerifyMigrationProofsForAnte_CheapStateAdmission`, and real-node `broadcast_tx_sync` coverage in `TestEVMigrationZeroSignerTxBroadcastSyncWithMempoolEnabled`, `TestEVMigrationProofValidNonexistentLegacyAccountRejectedByAnte`, `TestEVMigrationMalformedLegacyAddressRejectedByValidateBasic`, and `TestZeroSignerNonMigrationBroadcastSyncStillRejected`. diff --git a/docs/evm-integration/testing/tests.md b/docs/evm-integration/testing/tests.md index cd25a386..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 **~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 **~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) | 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) | 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 unit + 15 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 | @@ -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) | @@ -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 | 16 | 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+ | 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: ~398 · Integration: ~147 · Devnet: 12+ · Total: ~557** | | +| | **Totals** | **Unit: ~407 · Integration: ~150 · Devnet: 12+ · Total: ~569** | | ### Gaps and next steps @@ -109,13 +109,13 @@ 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 | | 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) | 16 | +| 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+ | +| 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 45428489..278cffd6 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,7 @@ 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. | +| `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 69f4f4ba..5e1d039e 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,7 @@ 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. | +| `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-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..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. | @@ -123,18 +117,45 @@ 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_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_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. | +| `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`. | +| `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. | +| `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 ### Multisig verifier tests (`x/evmigration/keeper/verify_test.go`) | 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`) @@ -143,7 +164,7 @@ Files: `x/evmigration/keeper/verify_test.go`, `x/evmigration/keeper/migrate_test | `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`) @@ -152,4 +173,4 @@ Files: `x/evmigration/keeper/verify_test.go`, `x/evmigration/keeper/migrate_test | `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/docs/evm-integration/user-guides/migration.md b/docs/evm-integration/user-guides/migration.md index 27d6608d..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. +> - **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..327ee876 --- /dev/null +++ b/tests/integration/evm/mempool/evmigration_zero_signer_test.go @@ -0,0 +1,232 @@ +//go:build integration +// +build integration + +package mempool_test + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "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" + 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" + + 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) + 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(), 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. +// +// 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, legacyPriv *secp256k1.PrivKey) []byte { + t.Helper() + + 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 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() + + 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..050dd9f3 100644 --- a/x/evmigration/keeper/ante.go +++ b/x/evmigration/keeper/ante.go @@ -2,16 +2,33 @@ package keeper 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" "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 +66,19 @@ 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 := k.verifyMigrationAdmissionState(ctx, msg, legacyAddr, newAddr); err != nil { + return err + } + if err := legacyProof.ValidateParams(params.MaxMultisigSubKeys); err != nil { return err } @@ -71,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 80dd6881..0514b7bc 100644 --- a/x/evmigration/keeper/ante_test.go +++ b/x/evmigration/keeper/ante_test.go @@ -3,26 +3,40 @@ package keeper_test import ( "strings" "testing" + "time" "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 @@ -32,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 @@ -41,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) @@ -53,3 +82,105 @@ 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 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()) + 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)) + 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) + }) +}