diff --git a/core/vm/evm.go b/core/vm/evm.go
index b9fd682b9a7..b9eca8cfb7f 100644
--- a/core/vm/evm.go
+++ b/core/vm/evm.go
@@ -187,7 +187,7 @@ func (evm *EVM) Interpreter() *EVMInterpreter {
// parameters. It also handles any necessary value transfer required and takes
// the necessary steps to create accounts and reverses the state in case of an
// execution error or failed value transfer.
-func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) (ret []byte, leftOverGas uint64, err error) {
+func (evm *EVM) call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) (ret []byte, leftOverGas uint64, err error) {
// Fail if we're trying to execute above the call depth limit
if evm.depth > int(params.CallCreateDepth) {
return nil, gas, ErrDepth
@@ -433,8 +433,8 @@ func (c *codeAndHash) Hash() common.Hash {
return c.hash
}
-// create creates a new contract using code as deployment code.
-func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *uint256.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
+// createCommon creates a new contract using code as deployment code.
+func (evm *EVM) createCommon(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *uint256.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
// Depth check execution. Fail if we're trying to execute above the
// limit.
if evm.depth > int(params.CallCreateDepth) {
diff --git a/core/vm/evm.libevm.go b/core/vm/evm.libevm.go
index c2c807c1378..b0d415bbacd 100644
--- a/core/vm/evm.libevm.go
+++ b/core/vm/evm.libevm.go
@@ -17,6 +17,8 @@
package vm
import (
+ "github.com/holiman/uint256"
+
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/libevm"
"github.com/ava-labs/libevm/log"
@@ -52,6 +54,42 @@ func (evm *EVM) canCreateContract(caller ContractRef, contractToCreate common.Ad
return gas, err
}
+// Call executes the contract associated with the addr with the given input as
+// parameters. It also handles any necessary value transfer required and takes
+// the necessary steps to create accounts and reverses the state in case of an
+// execution error or failed value transfer.
+func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas uint64, value *uint256.Int) (ret []byte, leftOverGas uint64, err error) {
+ gas, err = evm.spendPreprocessingGas(gas)
+ if err != nil {
+ return nil, gas, err
+ }
+ return evm.call(caller, addr, input, gas, value)
+}
+
+// create wraps the original geth method of the same name, now named
+// [EVM.createCommon], first spending preprocessing gas.
+func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *uint256.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
+ gas, err := evm.spendPreprocessingGas(gas)
+ if err != nil {
+ return nil, common.Address{}, gas, err
+ }
+ return evm.createCommon(caller, codeAndHash, gas, value, address, typ)
+}
+
+func (evm *EVM) spendPreprocessingGas(gas uint64) (uint64, error) {
+ if internalCall := evm.depth > 0; internalCall || !libevmHooks.Registered() {
+ return gas, nil
+ }
+ c, err := libevmHooks.Get().PreprocessingGasCharge(evm.StateDB.TxHash())
+ if err != nil {
+ return gas, err
+ }
+ if c > gas {
+ return 0, ErrOutOfGas
+ }
+ return gas - c, nil
+}
+
// InvalidateExecution sets the error that will be returned by
// [EVM.ExecutionInvalidated] for the length of the current transaction; i.e.
// until [EVM.Reset] is called. This is honoured by state-transition logic to
diff --git a/core/vm/evm.libevm_test.go b/core/vm/evm.libevm_test.go
index c0a33718e3d..deb7a0c6770 100644
--- a/core/vm/evm.libevm_test.go
+++ b/core/vm/evm.libevm_test.go
@@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/params"
)
@@ -46,6 +47,10 @@ func (o *evmArgOverrider) OverrideEVMResetArgs(r params.Rules, _ *EVMResetArgs)
}
}
+func (o *evmArgOverrider) PreprocessingGasCharge(common.Hash) (uint64, error) {
+ return 0, nil
+}
+
func (o *evmArgOverrider) register(t *testing.T) {
t.Helper()
TestOnlyClearRegisteredHooks()
diff --git a/core/vm/hooks.libevm.go b/core/vm/hooks.libevm.go
index 1e5acd49db9..a0ef69ba811 100644
--- a/core/vm/hooks.libevm.go
+++ b/core/vm/hooks.libevm.go
@@ -17,6 +17,7 @@
package vm
import (
+ "github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/libevm/register"
"github.com/ava-labs/libevm/params"
)
@@ -40,6 +41,14 @@ var libevmHooks register.AtMostOnce[Hooks]
type Hooks interface {
OverrideNewEVMArgs(*NewEVMArgs) *NewEVMArgs
OverrideEVMResetArgs(params.Rules, *EVMResetArgs) *EVMResetArgs
+ Preprocessor
+}
+
+// A Preprocessor performs computation on a transaction before the
+// [EVMInterpreter] is invoked and reports its gas charge for spending at the
+// beginning of [EVM.Call] or [EVM.Create].
+type Preprocessor interface {
+ PreprocessingGasCharge(tx common.Hash) (uint64, error)
}
// NewEVMArgs are the arguments received by [NewEVM], available for override
@@ -80,3 +89,19 @@ func (evm *EVM) overrideEVMResetArgs(txCtx TxContext, statedb StateDB) (TxContex
args := libevmHooks.Get().OverrideEVMResetArgs(evm.chainRules, &EVMResetArgs{txCtx, statedb})
return args.TxContext, args.StateDB
}
+
+// NOOPHooks implements [Hooks] such that every method is a noop.
+type NOOPHooks struct{}
+
+var _ Hooks = NOOPHooks{}
+
+// OverrideNewEVMArgs returns the args unchanged.
+func (NOOPHooks) OverrideNewEVMArgs(a *NewEVMArgs) *NewEVMArgs { return a }
+
+// OverrideEVMResetArgs returns the args unchanged.
+func (NOOPHooks) OverrideEVMResetArgs(_ params.Rules, a *EVMResetArgs) *EVMResetArgs {
+ return a
+}
+
+// PreprocessingGasCharge returns (0, nil).
+func (NOOPHooks) PreprocessingGasCharge(common.Hash) (uint64, error) { return 0, nil }
diff --git a/core/vm/interface.go b/core/vm/interface.go
index 4a9e15a6d3c..25ef393e863 100644
--- a/core/vm/interface.go
+++ b/core/vm/interface.go
@@ -82,6 +82,8 @@ type StateDB interface {
AddLog(*types.Log)
AddPreimage(common.Hash, []byte)
+
+ StateDBRemainder
}
// CallContext provides a basic interface for the EVM calling conventions. The EVM
diff --git a/core/vm/interface.libevm.go b/core/vm/interface.libevm.go
new file mode 100644
index 00000000000..ee999fcc8c5
--- /dev/null
+++ b/core/vm/interface.libevm.go
@@ -0,0 +1,27 @@
+// Copyright 2025 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package vm
+
+import "github.com/ava-labs/libevm/common"
+
+// StateDBRemainder defines methods not included in the geth definition of
+// [StateDB] but present on the concrete type and exposed for libevm
+// functionality.
+type StateDBRemainder interface {
+ TxHash() common.Hash
+ TxIndex() int
+}
diff --git a/core/vm/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go
new file mode 100644
index 00000000000..509682668c0
--- /dev/null
+++ b/core/vm/preprocess.libevm_test.go
@@ -0,0 +1,193 @@
+// Copyright 2025 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package vm_test
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "math/big"
+ "testing"
+
+ "github.com/holiman/uint256"
+ "github.com/stretchr/testify/assert"
+ "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/ethtest"
+ "github.com/ava-labs/libevm/params"
+)
+
+type preprocessingCharger struct {
+ vm.NOOPHooks
+ charge map[common.Hash]uint64
+}
+
+var errUnknownTx = errors.New("unknown tx")
+
+func (p preprocessingCharger) PreprocessingGasCharge(tx common.Hash) (uint64, error) {
+ c, ok := p.charge[tx]
+ if !ok {
+ return 0, fmt.Errorf("%w: %v", errUnknownTx, tx)
+ }
+ return c, nil
+}
+
+func TestChargePreprocessingGas(t *testing.T) {
+ tests := []struct {
+ name string
+ to *common.Address
+ charge uint64
+ skipChargeRegistration bool
+ txGas uint64
+ wantVMErr error
+ wantGasUsed uint64
+ }{
+ {
+ name: "standard create",
+ to: nil,
+ txGas: params.TxGas + params.CreateGas,
+ wantGasUsed: params.TxGas + params.CreateGas,
+ },
+ {
+ name: "create with extra charge",
+ to: nil,
+ charge: 1234,
+ txGas: params.TxGas + params.CreateGas + 2000,
+ wantGasUsed: params.TxGas + params.CreateGas + 1234,
+ },
+ {
+ name: "standard call",
+ to: &common.Address{},
+ txGas: params.TxGas,
+ wantGasUsed: params.TxGas,
+ },
+ {
+ name: "out of gas",
+ to: &common.Address{},
+ charge: 1000,
+ txGas: params.TxGas + 999,
+ wantGasUsed: params.TxGas + 999,
+ wantVMErr: vm.ErrOutOfGas,
+ },
+ {
+ name: "call with extra charge",
+ to: &common.Address{},
+ charge: 13579,
+ txGas: params.TxGas + 20000,
+ wantGasUsed: params.TxGas + 13579,
+ },
+ {
+ name: "error propagation",
+ to: &common.Address{},
+ skipChargeRegistration: true,
+ txGas: params.TxGas,
+ wantGasUsed: params.TxGas,
+ wantVMErr: errUnknownTx,
+ },
+ }
+
+ config := params.AllDevChainProtocolChanges
+ key, err := crypto.GenerateKey()
+ require.NoError(t, err, "crypto.GenerateKey()")
+ eoa := crypto.PubkeyToAddress(key.PublicKey)
+
+ header := &types.Header{
+ Number: big.NewInt(0),
+ Difficulty: big.NewInt(0),
+ BaseFee: big.NewInt(0),
+ }
+ signer := types.MakeSigner(config, header.Number, header.Time)
+
+ var txs types.Transactions
+ charge := make(map[common.Hash]uint64)
+ for i, tt := range tests {
+ tx := types.MustSignNewTx(key, signer, &types.LegacyTx{
+ // Although nonces aren't strictly necessary, they guarantee a
+ // different tx hash for each one.
+ Nonce: uint64(i),
+ To: tt.to,
+ GasPrice: big.NewInt(1),
+ Gas: tt.txGas,
+ })
+ txs = append(txs, tx)
+ if !tt.skipChargeRegistration {
+ charge[tx.Hash()] = tt.charge
+ }
+ }
+
+ vm.RegisterHooks(&preprocessingCharger{
+ charge: charge,
+ })
+ t.Cleanup(vm.TestOnlyClearRegisteredHooks)
+
+ for i, tt := range tests {
+ tx := txs[i]
+
+ t.Run(tt.name, func(t *testing.T) {
+ t.Logf("Extra gas charge: %d", tt.charge)
+
+ t.Run("ApplyTransaction", func(t *testing.T) {
+ _, _, sdb := ethtest.NewEmptyStateDB(t)
+ sdb.SetTxContext(tx.Hash(), i)
+ sdb.SetBalance(eoa, new(uint256.Int).SetAllOne())
+ sdb.SetNonce(eoa, tx.Nonce())
+
+ var gotGasUsed uint64
+ gp := core.GasPool(math.MaxUint64)
+
+ receipt, err := core.ApplyTransaction(
+ config, ethtest.DummyChainContext(), &common.Address{},
+ &gp, sdb, header, tx, &gotGasUsed, vm.Config{},
+ )
+ require.NoError(t, err, "core.ApplyTransaction(...)")
+
+ wantStatus := types.ReceiptStatusSuccessful
+ if tt.wantVMErr != nil {
+ wantStatus = types.ReceiptStatusFailed
+ }
+ assert.Equalf(t, wantStatus, receipt.Status, "%T.Status", receipt)
+
+ if got, want := gotGasUsed, tt.wantGasUsed; got != want {
+ t.Errorf("core.ApplyTransaction(..., &gotGasUsed, ...) got %d; want %d", got, want)
+ }
+ if got, want := receipt.GasUsed, tt.wantGasUsed; got != want {
+ t.Errorf("core.ApplyTransaction(...) -> %T.GasUsed = %d; want %d", receipt, got, want)
+ }
+ })
+
+ t.Run("VM_error", func(t *testing.T) {
+ sdb, evm := ethtest.NewZeroEVM(t, ethtest.WithChainConfig(config))
+ sdb.SetTxContext(tx.Hash(), i)
+ sdb.SetBalance(eoa, new(uint256.Int).SetAllOne())
+ sdb.SetNonce(eoa, tx.Nonce())
+
+ msg, err := core.TransactionToMessage(tx, signer, header.BaseFee)
+ require.NoError(t, err, "core.TransactionToMessage(...)")
+
+ gp := core.GasPool(math.MaxUint64)
+ got, err := core.ApplyMessage(evm, msg, &gp)
+ require.NoError(t, err, "core.ApplyMessage(...)")
+ require.ErrorIsf(t, got.Err, tt.wantVMErr, "%T.Err", got)
+ })
+ })
+ }
+}
diff --git a/libevm/ethtest/dummy.go b/libevm/ethtest/dummy.go
new file mode 100644
index 00000000000..e800a513d27
--- /dev/null
+++ b/libevm/ethtest/dummy.go
@@ -0,0 +1,44 @@
+// Copyright 2025 the libevm authors.
+//
+// The libevm additions to go-ethereum are free software: you can redistribute
+// them and/or modify them under the terms of the GNU Lesser General Public License
+// as published by the Free Software Foundation, either version 3 of the License,
+// or (at your option) any later version.
+//
+// The libevm additions are distributed in the hope that they will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
+// General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with the go-ethereum library. If not, see
+// .
+
+package ethtest
+
+import (
+ "github.com/ava-labs/libevm/common"
+ "github.com/ava-labs/libevm/consensus"
+ "github.com/ava-labs/libevm/core"
+ "github.com/ava-labs/libevm/core/types"
+)
+
+// DummyChainContext returns a dummy that returns [DummyEngine] when its
+// Engine() method is called, and panics when its GetHeader() method is called.
+func DummyChainContext() core.ChainContext {
+ return chainContext{}
+}
+
+// DummyEngine returns a dummy that panics when its Author() method is called.
+func DummyEngine() consensus.Engine {
+ return engine{}
+}
+
+type (
+ chainContext struct{}
+ engine struct{ consensus.Engine }
+)
+
+func (chainContext) Engine() consensus.Engine { return engine{} }
+func (chainContext) GetHeader(common.Hash, uint64) *types.Header { panic("unimplemented") }
+func (engine) Author(h *types.Header) (common.Address, error) { panic("unimplemented") }
diff --git a/libevm/ethtest/evm.go b/libevm/ethtest/evm.go
index 4e16c4e90bb..7a7b463295e 100644
--- a/libevm/ethtest/evm.go
+++ b/libevm/ethtest/evm.go
@@ -23,14 +23,28 @@ import (
"github.com/stretchr/testify/require"
- "github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core"
"github.com/ava-labs/libevm/core/rawdb"
"github.com/ava-labs/libevm/core/state"
+ "github.com/ava-labs/libevm/core/types"
"github.com/ava-labs/libevm/core/vm"
+ "github.com/ava-labs/libevm/ethdb"
"github.com/ava-labs/libevm/params"
)
+// NewEmptyStateDB returns a fresh database from [rawdb.NewMemoryDatabase], a
+// [state.Database] wrapping it, and a [state.StateDB] wrapping that, opened to
+// [types.EmptyRootHash].
+func NewEmptyStateDB(tb testing.TB) (ethdb.Database, state.Database, *state.StateDB) {
+ tb.Helper()
+
+ db := rawdb.NewMemoryDatabase()
+ cache := state.NewDatabase(db)
+ sdb, err := state.New(types.EmptyRootHash, cache, nil)
+ require.NoError(tb, err, "state.New()")
+ return db, cache, sdb
+}
+
// NewZeroEVM returns a new EVM backed by a [rawdb.NewMemoryDatabase]; all other
// arguments to [vm.NewEVM] are the zero values of their respective types,
// except for the use of [core.CanTransfer] and [core.Transfer] instead of nil
@@ -38,8 +52,7 @@ import (
func NewZeroEVM(tb testing.TB, opts ...EVMOption) (*state.StateDB, *vm.EVM) {
tb.Helper()
- sdb, err := state.New(common.Hash{}, state.NewDatabase(rawdb.NewMemoryDatabase()), nil)
- require.NoError(tb, err, "state.New()")
+ _, _, sdb := NewEmptyStateDB(tb)
args := &evmConstructorArgs{
vm.BlockContext{