feat: RulesHooks.MinimumGasConsumption (#185)

## Why this should be merged

Required for
[ACP-194](https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/194-streaming-asynchronous-execution#gas-charged)
$\lambda$ bound on gas consumption.

## How this works

Hook into `core.StateTransition.TransitionDb()` as this is the bottom of
all execution paths (e.g. `core.ApplyTransaction()` as used in SAE,
`core.StateProcessor.Process(*Block,...)`, etc.). Once consumed gas is
no longer changing (i.e. after all spends and refunds), the transaction
limit is passed to the hook to determine the minimum consumption, which
is applied.

## How this was tested

Unit test via `core.ApplyTransaction()` as this is our entry point in
SAE.

---------

Signed-off-by: Arran Schlosberg <519948+ARR4N@users.noreply.github.com>
Co-authored-by: Stephen Buttolph <stephen@avalabs.org>
This commit is contained in:
Arran Schlosberg 2025-06-11 19:22:33 +01:00 committed by GitHub
parent 2672fbd7cd
commit bedfd12e2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 195 additions and 4 deletions

View file

@ -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))

View file

@ -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,
)
}

View file

@ -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)
}
})
}
}

View file

@ -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

View file

@ -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
}