diff --git a/CHANGELOG.md b/CHANGELOG.md index 958110e170..7c3fddc0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * (fix) [#878](https://github.com/crypto-org-chain/ethermint/pull/878) Patch default history serve window with correct value. * (evm) [#896](https://github.com/crypto-org-chain/ethermint/pull/896) fix: burn post-SELFDESTRUCT balance at commit to prevent fund recovery via address recreation * (rpc) [#916](https://github.com/crypto-org-chain/ethermint/pull/916) fix(rpc): return InvalidParams error when `eth_getLogs` toBlock exceeds chain head. +* (evm) [#921](https://github.com/crypto-org-chain/ethermint/pull/921) fix: enforce floor-data-gas * (test) [#926](https://github.com/crypto-org-chain/ethermint/pull/926) fix(test): remove flaky `base_fee` assertion in `update_feemarket_param`. ## [v0.23.0] - 2026-01-13 diff --git a/x/evm/handler_test.go b/x/evm/handler_test.go index b2535179d9..ab16a57478 100644 --- a/x/evm/handler_test.go +++ b/x/evm/handler_test.go @@ -418,7 +418,10 @@ func (suite *HandlerTestSuite) deployERC20Contract() common.Address { // - when transaction reverted, gas refund works. // - when transaction reverted, nonce is still increased. func (suite *HandlerTestSuite) TestERC20TransferReverted() { - intrinsicGas := uint64(21572) + transferData, err := types.ERC20Contract.ABI.Pack("transfer", suite.Address, big.NewInt(10)) + suite.Require().NoError(err) + floorDataGas, err := core.FloorDataGas(transferData) + suite.Require().NoError(err) // test different hooks scenarios testCases := []struct { msg string @@ -428,13 +431,13 @@ func (suite *HandlerTestSuite) TestERC20TransferReverted() { }{ { "no hooks", - intrinsicGas, // enough for intrinsicGas, but not enough for execution + floorDataGas, // enough for floorDataGas, but not enough for execution nil, "out of gas", }, { "success hooks", - intrinsicGas, // enough for intrinsicGas, but not enough for execution + floorDataGas, // enough for floorDataGas, but not enough for execution &DummyHook{}, "out of gas", }, @@ -484,8 +487,8 @@ func (suite *HandlerTestSuite) TestERC20TransferReverted() { rules := params.Rules{ IsHomestead: true, - IsIstanbul: true, - IsShanghai: true, + IsIstanbul: true, + IsShanghai: true, } fees, err := keeper.VerifyFee(tx, "aphoton", baseFee, rules, suite.Ctx.IsCheckTx()) suite.Require().NoError(err) @@ -518,7 +521,11 @@ func (suite *HandlerTestSuite) TestERC20TransferReverted() { } func (suite *HandlerTestSuite) TestContractDeploymentRevert() { - intrinsicGas := uint64(134510) + ctorArgsForFloor, err := types.ERC20Contract.ABI.Pack("", suite.Address, big.NewInt(0)) + suite.Require().NoError(err) + deployData := append(types.ERC20Contract.Bin, ctorArgsForFloor...) + floorDataGas, err := core.FloorDataGas(deployData) + suite.Require().NoError(err) testCases := []struct { msg string gasLimit uint64 @@ -526,12 +533,12 @@ func (suite *HandlerTestSuite) TestContractDeploymentRevert() { }{ { "no hooks", - intrinsicGas, + floorDataGas, nil, }, { "success hooks", - intrinsicGas, + floorDataGas, &DummyHook{}, }, } diff --git a/x/evm/keeper/integration_test.go b/x/evm/keeper/integration_test.go index 98c7689b4e..178522490f 100644 --- a/x/evm/keeper/integration_test.go +++ b/x/evm/keeper/integration_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "bytes" "math/big" "testing" @@ -119,6 +120,45 @@ var _ = Describe("Evm", func() { ) }) }) + + // EIP-7623: Prague calldata floor gas must be enforced in both CheckTx and DeliverTx. + // + // For 1024 non-zero calldata bytes: + // intrinsicGas = 21000 + 16 * 1024 = 37384 + // floorDataGas = 21000 + 10 * 4 * 1024 = 61960 + // + // A transaction with gasLimit = intrinsicGas is below the floor and must be rejected + // in both CheckTx (mempool admission) and DeliverTx (block execution). + Context("EIP-7623 Prague calldata floor gas", func() { + const ( + intrinsicGas = uint64(21000 + 16*1024) // 37384 + floorDataGas = uint64(21000 + 10*4*1024) // 61960 + ) + + BeforeEach(func() { + setupTest(sdkmath.LegacyZeroDec(), big.NewInt(0)) + }) + + It("CheckTx rejects gasLimit below floor", func() { + txBz := prepareFloorDataGasTx(intrinsicGas) + res := s.CheckTx(txBz) + Expect(res.IsOK()).To(BeFalse(), "CheckTx must reject below-floor Prague tx, got log: %s", res.GetLog()) + }) + + It("DeliverTx rejects gasLimit below floor (fix validates block execution path)", func() { + txBz := prepareFloorDataGasTx(intrinsicGas) + res := s.DeliverTx(txBz) + Expect(res.IsOK()).To(BeFalse(), + "DeliverTx must also reject below-floor Prague tx, got log: %s", res.GetLog()) + }) + + It("DeliverTx accepts gasLimit at floor", func() { + txBz := prepareFloorDataGasTx(floorDataGas) + res := s.DeliverTx(txBz) + Expect(res.IsOK()).To(BeTrue(), + "DeliverTx must accept gasLimit == floorDataGas, got log: %s", res.GetLog()) + }) + }) }) }) @@ -158,3 +198,16 @@ func prepareEthTx(p txParams) []byte { msg := s.BuildEthTx(&to, p.gasLimit, p.gasPrice, p.gasFeeCap, p.gasTipCap, p.accesses, s.PrivKey) return s.PrepareEthTx(msg, s.PrivKey) } + +// prepareFloorDataGasTx builds a legacy tx with 1024 non-zero calldata bytes and the +// given gasLimit, signed with the test account. It does NOT use BuildEthTx because that +// helper does not accept calldata. +func prepareFloorDataGasTx(gasLimit uint64) []byte { + calldata := bytes.Repeat([]byte{0xff}, 1024) + chainID := s.App.EvmKeeper.ChainID() + nonce := s.App.EvmKeeper.GetNonce(s.Ctx, s.Address) + to := tests.GenerateAddress() + msg := evmtypes.NewTx(chainID, nonce, &to, nil, gasLimit, big.NewInt(0), nil, nil, calldata, nil) + msg.From = s.Address.Bytes() + return s.PrepareEthTx(msg, s.PrivKey) +} diff --git a/x/evm/keeper/migrations.go b/x/evm/keeper/migrations.go index caa891d6b4..1f42fade5b 100644 --- a/x/evm/keeper/migrations.go +++ b/x/evm/keeper/migrations.go @@ -57,3 +57,8 @@ func (m Migrator) Migrate5to6(ctx sdk.Context) error { func (m Migrator) Migrate6to7(ctx sdk.Context) error { return v7.MigrateStore(ctx, m.keeper.storeKey, m.keeper.cdc) } + +// Migrate7to8 migrates the store from consensus version 7 to 8. +func (m Migrator) Migrate7to8(_ sdk.Context) error { + return nil +} diff --git a/x/evm/keeper/simulate.go b/x/evm/keeper/simulate.go index f0d817650c..9bd425dbdf 100644 --- a/x/evm/keeper/simulate.go +++ b/x/evm/keeper/simulate.go @@ -368,6 +368,17 @@ func (sim *Simulator) applyCall( } leftoverGas -= intrinsicGas + // Enforce EIP-7623 floor data gas for Prague (mirrors ApplyMessageWithConfig). + if rules.IsPrague { + floorDataGas, err := core.FloorDataGas(msg.Data) + if err != nil { + return applyCallResult{}, err + } + if msg.GasLimit < floorDataGas { + return applyCallResult{}, core.ErrFloorDataGas + } + } + // Shanghai init code size check if rules.IsShanghai && contractCreation && len(msg.Data) > params.MaxInitCodeSize { return applyCallResult{}, fmt.Errorf("%w: code size %v limit %v", core.ErrMaxInitCodeSizeExceeded, len(msg.Data), params.MaxInitCodeSize) @@ -408,6 +419,20 @@ func (sim *Simulator) applyCall( refund := GasToRefund(sim.state.GetRefund(), temporaryGasUsed, refundQuotient) leftoverGas += refund + // Apply EIP-7623 post-execution floor on post-refund gas used. + if rules.IsPrague { + floorDataGas, err := core.FloorDataGas(msg.Data) + if err != nil { + return applyCallResult{}, err + } + if msg.GasLimit-leftoverGas < floorDataGas { + leftoverGas = msg.GasLimit - floorDataGas + if temporaryGasUsed < floorDataGas { + temporaryGasUsed = floorDataGas + } + } + } + gasUsed := msg.GasLimit - leftoverGas // charge gas diff --git a/x/evm/keeper/state_transition.go b/x/evm/keeper/state_transition.go index 76bd50a7bb..de56e2d2ea 100644 --- a/x/evm/keeper/state_transition.go +++ b/x/evm/keeper/state_transition.go @@ -414,6 +414,19 @@ func (k *Keeper) ApplyMessageWithConfig( tracer.OnGasChange(msg.GasLimit, leftoverGas, tracing.GasChangeTxIntrinsicGas) } + // Enforce EIP-7623 floor data gas for Prague in all execution contexts, + // including eth_call and FinalizeBlock paths that bypass the ante handler. + if rules.IsPrague { + floorDataGas, err := core.FloorDataGas(msg.Data) + if err != nil { + return nil, errorsmod.Wrap(err, "floor data gas") + } + if msg.GasLimit < floorDataGas { + return nil, errorsmod.Wrapf(core.ErrFloorDataGas, + "gas %d, minimum needed %d", msg.GasLimit, floorDataGas) + } + } + // access list preparation is moved from ante handler to here, because it's needed when `ApplyMessage` is called // under contexts where ante handlers are not run, for example `eth_call` and `eth_estimateGas`. // Check whether the init code size has been exceeded. @@ -475,6 +488,24 @@ func (k *Keeper) ApplyMessageWithConfig( tracer.OnGasChange(leftoverGas-refund, leftoverGas, tracing.GasChangeTxRefunds) } + // Apply EIP-7623 post-execution floor: enforce on post-refund gas used. + // leftoverGas already includes the refund at this point, so + // (msg.GasLimit - leftoverGas) is the post-refund gas used. + if rules.IsPrague { + floorDataGas, err := core.FloorDataGas(msg.Data) + if err != nil { + return nil, errorsmod.Wrap(err, "floor data gas") + } + if msg.GasLimit-leftoverGas < floorDataGas { + prev := leftoverGas + leftoverGas = msg.GasLimit - floorDataGas + if tracer != nil && tracer.OnGasChange != nil { + tracer.OnGasChange(prev, leftoverGas, tracing.GasChangeTxDataFloor) + } + } + } + temporaryGasUsed = msg.GasLimit - leftoverGas + // EVM execution error needs to be available for the JSON-RPC client var vmError string if vmErr != nil { diff --git a/x/evm/keeper/state_transition_test.go b/x/evm/keeper/state_transition_test.go index 9882de59e4..4ee26662eb 100644 --- a/x/evm/keeper/state_transition_test.go +++ b/x/evm/keeper/state_transition_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "bytes" "fmt" "math" "math/big" @@ -865,3 +866,88 @@ func (suite *StateTransitionTestSuite) TestBlobBaseFeeOpcode() { suite.Require().Equal(expected, result.Ret, "BLOBBASEFEE should return overridden value 42") }) } + +// TestPragueFloorDataGas verifies EIP-7623 floor data gas enforcement in ApplyMessageWithConfig. +// A transaction whose gasLimit >= intrinsicGas but < floorDataGas must be rejected, +// and when gasLimit >= floorDataGas the charged gas must be at least floorDataGas. +func (suite *StateTransitionTestSuite) TestPragueFloorDataGas() { + suite.SetupTest() + + calldata := bytes.Repeat([]byte{0xff}, 1024) + ethCfg := suite.App.EvmKeeper.GetParams(suite.Ctx).ChainConfig.EthereumConfig(suite.App.EvmKeeper.ChainID()) + rules := ethCfg.Rules(big.NewInt(suite.Ctx.BlockHeight()), ethCfg.MergeNetsplitBlock != nil, uint64(suite.Ctx.BlockHeader().Time.Unix())) + intrinsicGas, err := suite.App.EvmKeeper.GetEthIntrinsicGas(&core.Message{To: &suite.Address, Data: calldata}, rules, false) + suite.Require().NoError(err) + floorDataGas, err := core.FloorDataGas(calldata) + suite.Require().NoError(err) + suite.Require().Less(intrinsicGas, floorDataGas, "test invariant: floor > intrinsic") + + to := suite.Address + cfg, err := suite.App.EvmKeeper.EVMConfig(suite.Ctx, suite.App.EvmKeeper.ChainID(), common.Hash{}) + suite.Require().NoError(err) + cfg.TxConfig = suite.App.EvmKeeper.TxConfig(suite.Ctx, common.Hash{}) + + suite.Require().True(cfg.Rules.IsPrague, "Prague must be active for this test") + + suite.Run("rejects gasLimit below floor", func() { + msg := &core.Message{ + To: &to, + From: suite.Address, + Nonce: suite.StateDB().GetNonce(suite.Address), + Value: big.NewInt(0), + GasLimit: intrinsicGas, + GasPrice: big.NewInt(0), + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + Data: calldata, + SkipNonceChecks: true, + } + + _, err := suite.App.EvmKeeper.ApplyMessageWithConfig(suite.Ctx, msg, cfg, true) + suite.Require().Error(err, "must reject gasLimit < floorDataGas under Prague") + suite.Require().Contains(err.Error(), "floor data gas") + }) + + suite.Run("accepts gasLimit at floor and charges floor gas", func() { + msg := &core.Message{ + To: &to, + From: suite.Address, + Nonce: suite.StateDB().GetNonce(suite.Address), + Value: big.NewInt(0), + GasLimit: floorDataGas, // exactly at floor + GasPrice: big.NewInt(0), + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + Data: calldata, + SkipNonceChecks: true, + } + + result, err := suite.App.EvmKeeper.ApplyMessageWithConfig(suite.Ctx, msg, cfg, true) + suite.Require().NoError(err) + suite.Require().False(result.Failed()) + // Charged gas must equal floorDataGas even though actual EVM execution cost < floor. + suite.Require().GreaterOrEqual(result.GasUsed, floorDataGas, + "gasUsed must be at least floorDataGas under Prague EIP-7623") + }) + + suite.Run("accepts gasLimit above floor and charges at least floor gas", func() { + msg := &core.Message{ + To: &to, + From: suite.Address, + Nonce: suite.StateDB().GetNonce(suite.Address), + Value: big.NewInt(0), + GasLimit: floorDataGas * 2, // well above floor + GasPrice: big.NewInt(0), + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + Data: calldata, + SkipNonceChecks: true, + } + + result, err := suite.App.EvmKeeper.ApplyMessageWithConfig(suite.Ctx, msg, cfg, true) + suite.Require().NoError(err) + suite.Require().False(result.Failed()) + suite.Require().GreaterOrEqual(result.GasUsed, floorDataGas, + "gasUsed must be at least floorDataGas under Prague EIP-7623") + }) +} diff --git a/x/evm/keeper/utils.go b/x/evm/keeper/utils.go index ca31fce590..0b413db081 100644 --- a/x/evm/keeper/utils.go +++ b/x/evm/keeper/utils.go @@ -132,7 +132,7 @@ func VerifyFee( } // Gas limit suffices for the floor data cost (EIP-7623) - if isCheckTx && rules.IsPrague { + if rules.IsPrague { floorDataGas, err := core.FloorDataGas(tx.Data()) if err != nil { return nil, err diff --git a/x/evm/module.go b/x/evm/module.go index 557b27cc7a..64522a1ed2 100644 --- a/x/evm/module.go +++ b/x/evm/module.go @@ -62,7 +62,7 @@ func (AppModuleBasic) RegisterLegacyAminoCodec(cdc *codec.LegacyAmino) { // ConsensusVersion returns the consensus state-breaking version for the module. func (AppModule) ConsensusVersion() uint64 { - return 7 + return 8 } // DefaultGenesis returns default genesis state as raw bytes for the evm @@ -146,6 +146,10 @@ func (am AppModule) RegisterServices(cfg module.Configurator) { if err := cfg.RegisterMigration(types.ModuleName, 6, m.Migrate6to7); err != nil { panic(err) } + + if err := cfg.RegisterMigration(types.ModuleName, 7, m.Migrate7to8); err != nil { + panic(err) + } } // BeginBlock returns the begin block for the evm module.