diff --git a/core/state_transition.go b/core/state_transition.go index 509403be610..f588a649e48 100644 --- a/core/state_transition.go +++ b/core/state_transition.go @@ -478,6 +478,8 @@ func (st *StateTransition) refundGas(refundQuotient uint64) uint64 { } st.gasRemaining += refund + st.consumeMinimumGas() // libevm: see comment on method re call-site requirements + // Return ETH for remaining gas, exchanged at the original rate. remaining := uint256.NewInt(st.gasRemaining) remaining = remaining.Mul(remaining, uint256.MustFromBig(st.msg.GasPrice)) diff --git a/core/state_transition.libevm.go b/core/state_transition.libevm.go index 701cc4d5ee7..bb9c3eff469 100644 --- a/core/state_transition.libevm.go +++ b/core/state_transition.libevm.go @@ -18,22 +18,44 @@ package core import ( "github.com/ava-labs/libevm/log" + "github.com/ava-labs/libevm/params" ) +func (st *StateTransition) rulesHooks() params.RulesHooks { + bCtx := st.evm.Context + rules := st.evm.ChainConfig().Rules(bCtx.BlockNumber, bCtx.Random != nil, bCtx.Time) + return rules.Hooks() +} + // canExecuteTransaction is a convenience wrapper for calling the // [params.RulesHooks.CanExecuteTransaction] hook. func (st *StateTransition) canExecuteTransaction() error { - bCtx := st.evm.Context - rules := st.evm.ChainConfig().Rules(bCtx.BlockNumber, bCtx.Random != nil, bCtx.Time) - if err := rules.Hooks().CanExecuteTransaction(st.msg.From, st.msg.To, st.state); err != nil { + hooks := st.rulesHooks() + if err := hooks.CanExecuteTransaction(st.msg.From, st.msg.To, st.state); err != nil { log.Debug( "Transaction execution blocked by libevm hook", "from", st.msg.From, "to", st.msg.To, - "hooks", log.TypeOf(rules.Hooks()), + "hooks", log.TypeOf(hooks), "reason", err, ) return err } return nil } + +// consumeMinimumGas updates the gas remaining to reflect the value returned by +// [params.RulesHooks.MinimumGasConsumption]. It MUST be called after all code +// that modifies gas consumption but before the balance is returned for +// remaining gas. +func (st *StateTransition) consumeMinimumGas() { + limit := st.msg.GasLimit + minConsume := min( + limit, // as documented in [params.RulesHooks] + st.rulesHooks().MinimumGasConsumption(limit), + ) + st.gasRemaining = min( + st.gasRemaining, + limit-minConsume, + ) +} diff --git a/core/state_transition.libevm_test.go b/core/state_transition.libevm_test.go index 74ab211a79e..5e13d47dfb7 100644 --- a/core/state_transition.libevm_test.go +++ b/core/state_transition.libevm_test.go @@ -17,15 +17,21 @@ package core_test import ( "fmt" + "math/big" "testing" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/core/vm" + "github.com/ava-labs/libevm/crypto" "github.com/ava-labs/libevm/libevm" "github.com/ava-labs/libevm/libevm/ethtest" "github.com/ava-labs/libevm/libevm/hookstest" + "github.com/ava-labs/libevm/params" ) func TestCanExecuteTransaction(t *testing.T) { @@ -54,3 +60,143 @@ func TestCanExecuteTransaction(t *testing.T) { _, err := core.ApplyMessage(evm, msg, new(core.GasPool).AddGas(30e6)) require.EqualError(t, err, makeErr(msg.From, msg.To, value).Error()) } + +func TestMinimumGasConsumption(t *testing.T) { + // All transactions will be basic transfers so consume [params.TxGas] by + // default. + tests := []struct { + name string + gasLimit uint64 + refund uint64 + minConsumption uint64 + wantUsed uint64 + }{ + { + name: "consume_extra", + gasLimit: 1e6, + minConsumption: 5e5, + wantUsed: 5e5, + }, + { + name: "consume_extra", + gasLimit: 1e6, + minConsumption: 4e5, + wantUsed: 4e5, + }, + { + name: "no_extra_consumption", + gasLimit: 50_000, + minConsumption: params.TxGas - 1, + wantUsed: params.TxGas, + }, + { + name: "zero_min", + gasLimit: 50_000, + minConsumption: 0, + wantUsed: params.TxGas, + }, + { + name: "consume_extra_by_one", + gasLimit: 1e6, + minConsumption: params.TxGas + 1, + wantUsed: params.TxGas + 1, + }, + { + name: "min_capped_at_limit", + gasLimit: 1e6, + minConsumption: 2e6, + wantUsed: 1e6, + }, + { + // Although this doesn't test minimum consumption, it demonstrates + // the expected outcome for comparison with the next test. + name: "refund_without_min_consumption", + gasLimit: 1e6, + refund: 1, + wantUsed: params.TxGas - 1, + }, + { + name: "refund_with_min_consumption", + gasLimit: 1e6, + refund: 1, + minConsumption: params.TxGas, + wantUsed: params.TxGas, + }, + } + + // Very low gas price so we can calculate the expected balance in a uint64, + // but not 1 otherwise tests would pass without multiplying extra + // consumption by the price. + const gasPrice = 3 + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hooks := &hookstest.Stub{ + MinimumGasConsumptionFn: func(limit uint64) uint64 { + require.Equal(t, tt.gasLimit, limit) + return tt.minConsumption + }, + } + hooks.Register(t) + + key, err := crypto.GenerateKey() + require.NoError(t, err, "libevm/crypto.GenerateKey()") + + stateDB, evm := ethtest.NewZeroEVM(t) + signer := types.LatestSigner(evm.ChainConfig()) + tx := types.MustSignNewTx( + key, signer, + &types.LegacyTx{ + GasPrice: big.NewInt(gasPrice), + Gas: tt.gasLimit, + To: &common.Address{}, + Value: big.NewInt(0), + }, + ) + + const startingBalance = 10 * params.Ether + from := crypto.PubkeyToAddress(key.PublicKey) + stateDB.SetNonce(from, 0) + stateDB.SetBalance(from, uint256.NewInt(startingBalance)) + stateDB.AddRefund(tt.refund) + + var ( + // Both variables are passed as pointers to + // [core.ApplyTransaction], which will modify them. + gotUsed uint64 + gotPool = core.GasPool(1e9) + ) + wantPool := gotPool - core.GasPool(tt.wantUsed) + + receipt, err := core.ApplyTransaction( + evm.ChainConfig(), nil, &common.Address{}, &gotPool, stateDB, + &types.Header{ + BaseFee: big.NewInt(gasPrice), + // Required but irrelevant fields + Number: big.NewInt(0), + Difficulty: big.NewInt(0), + }, + tx, &gotUsed, vm.Config{}, + ) + require.NoError(t, err, "core.ApplyTransaction(...)") + + for desc, got := range map[string]uint64{ + "receipt.GasUsed": receipt.GasUsed, + "receipt.CumulativeGasUsed": receipt.CumulativeGasUsed, + "core.ApplyTransaction(..., usedGas *uint64, ...)": gotUsed, + } { + if got != tt.wantUsed { + t.Errorf("%s got %d; want %d", desc, got, tt.wantUsed) + } + } + if gotPool != wantPool { + t.Errorf("After core.ApplyMessage(..., *%T); got %[1]T = %[1]d; want %d", gotPool, wantPool) + } + + wantBalance := uint256.NewInt(startingBalance - tt.wantUsed*gasPrice) + if got := stateDB.GetBalance(from); !got.Eq(wantBalance) { + t.Errorf("got remaining balance %d; want %d", got, wantBalance) + } + }) + } +} diff --git a/libevm/hookstest/stub.go b/libevm/hookstest/stub.go index 1e37aa3f940..0a656c25c4f 100644 --- a/libevm/hookstest/stub.go +++ b/libevm/hookstest/stub.go @@ -48,6 +48,7 @@ type Stub struct { ActivePrecompilesFn func([]common.Address) []common.Address CanExecuteTransactionFn func(common.Address, *common.Address, libevm.StateReader) error CanCreateContractFn func(*libevm.AddressContext, uint64, libevm.StateReader) (uint64, error) + MinimumGasConsumptionFn func(txGasLimit uint64) uint64 } // Register is a convenience wrapper for registering s as both the @@ -122,6 +123,15 @@ func (s Stub) CanCreateContract(cc *libevm.AddressContext, gas uint64, sr libevm return gas, nil } +// MinimumGasConsumption proxies arguments to the s.MinimumGasConsumptionFn +// function if non-nil, otherwise it acts as a noop. +func (s Stub) MinimumGasConsumption(limit uint64) uint64 { + if f := s.MinimumGasConsumptionFn; f != nil { + return f(limit) + } + return 0 +} + var _ interface { params.ChainConfigHooks params.RulesHooks diff --git a/params/hooks.libevm.go b/params/hooks.libevm.go index 9faf403a65d..dd08701130e 100644 --- a/params/hooks.libevm.go +++ b/params/hooks.libevm.go @@ -55,6 +55,12 @@ type RulesHooks interface { // received slice. The value it returns MUST be consistent with the // behaviour of the PrecompileOverride hook. ActivePrecompiles([]common.Address) []common.Address + // MinimumGasConsumption receives a transaction's gas limit and returns the + // minimum quantity of gas units to be charged for said transaction. If the + // returned value is greater than the transaction's limit, the minimum spend + // will be capped at the limit. The minimum spend will be applied _after_ + // refunds, if any. + MinimumGasConsumption(txGasLimit uint64) (gas uint64) } // RulesAllowlistHooks are a subset of [RulesHooks] that gate actions, signalled @@ -132,3 +138,8 @@ func (NOOPHooks) PrecompileOverride(common.Address) (libevm.PrecompiledContract, func (NOOPHooks) ActivePrecompiles(active []common.Address) []common.Address { return active } + +// MinimumGasConsumption always returns 0. +func (NOOPHooks) MinimumGasConsumption(uint64) uint64 { + return 0 +}