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..0e902bec427 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 name +// [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 evm.depth > 0 || !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/go.mod b/go.mod index 7a814eec1ec..e7dc2f5b1c0 100644 --- a/go.mod +++ b/go.mod @@ -64,6 +64,7 @@ require ( github.com/tyler-smith/go-bip39 v1.1.0 github.com/urfave/cli/v2 v2.25.7 go.uber.org/automaxprocs v1.5.2 + go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.17.0 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa golang.org/x/mod v0.14.0 diff --git a/go.sum b/go.sum index 2156147c18f..87821192c5e 100644 --- a/go.sum +++ b/go.sum @@ -622,6 +622,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.uber.org/automaxprocs v1.5.2 h1:2LxUOGiR3O6tw8ui5sZa2LAaHnsviZdVOUZw4fvbnME= go.uber.org/automaxprocs v1.5.2/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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{ diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go new file mode 100644 index 00000000000..4caca882898 --- /dev/null +++ b/libevm/precompiles/parallel/parallel.go @@ -0,0 +1,304 @@ +// 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 parallel provides functionality for precompiled contracts that can +// pre-process their results in an embarrassingly parallel fashion. +package parallel + +import ( + "errors" + "fmt" + "sync" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core" + "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/libevm" + "github.com/ava-labs/libevm/params" +) + +// A Handler is responsible for processing [types.Transactions] in an +// embarrassingly parallel fashion. It is the responsibility of the Handler to +// determine whether this is possible, typically only so if one of the following +// is true with respect to a precompile associated with the Handler: +// +// 1. The destination address is that of the precompile; or +// +// 2. At least one [types.AccessTuple] references the precompile's address. +// +// Scenario (2) allows precompile access to be determined through inspection of +// the [types.Transaction] alone, without the need for execution. +type Handler[Result any] interface { + BeforeBlock(*types.Header) + Gas(*types.Transaction) (gas uint64, process bool) + Process(sdb libevm.StateReader, index int, tx *types.Transaction) Result +} + +// A Processor orchestrates dispatch and collection of results from a [Handler]. +type Processor[R any] struct { + handler Handler[R] + workers sync.WaitGroup + work chan *job + results [](chan result[R]) + txGas map[common.Hash]uint64 + stateShare stateDBSharer +} + +type job struct { + index int + tx *types.Transaction +} + +type result[T any] struct { + tx common.Hash + val *T +} + +// New constructs a new [Processor] with the specified number of concurrent +// workers. [Processor.Close] must be called after the final call to +// [Processor.FinishBlock] to avoid leaking goroutines. +func New[R any](h Handler[R], workers int) *Processor[R] { + workers = max(workers, 1) + + p := &Processor[R]{ + handler: h, + work: make(chan *job), + txGas: make(map[common.Hash]uint64), + stateShare: stateDBSharer{ + workers: workers, + nextAvailable: make(chan struct{}), + }, + } + + p.workers.Add(workers) // for shutdown via [Processor.Close] + p.stateShare.wg.Add(workers) // for readiness of [Processor.worker] loops + for range workers { + go p.worker() + } + p.stateShare.wg.Wait() + + return p +} + +// A stateDBSharer allows concurrent workers to make copies of a primary +// database. When the `nextAvailable` channel is closed, all workers call +// [state.StateDB.Copy] then signal completion on the [sync.WaitGroup]. The +// channel is replaced for each round of distribution. +type stateDBSharer struct { + nextAvailable chan struct{} + primary *state.StateDB + mu sync.Mutex + workers int + wg sync.WaitGroup +} + +func (s *stateDBSharer) distribute(sdb *state.StateDB) { + s.primary = sdb // no need to Copy() as each worker does it + + ch := s.nextAvailable // already copied by [Processor.worker], which is waiting for it to close + s.nextAvailable = make(chan struct{}) // will be copied, ready for the next distribution + + s.wg.Add(s.workers) + close(ch) + s.wg.Wait() +} + +func (p *Processor[R]) worker() { + defer p.workers.Done() + + var sdb *state.StateDB + share := &p.stateShare + stateAvailable := share.nextAvailable + // Without this signal of readiness, a premature call to + // [Processor.StartBlock] could replace `share.nextAvailable` before we've + // copied it. + share.wg.Done() + + for { + select { + case <-stateAvailable: // guaranteed at the beginning of each block + share.mu.Lock() + sdb = share.primary.Copy() + share.mu.Unlock() + + stateAvailable = share.nextAvailable + share.wg.Done() + + case w, ok := <-p.work: + if !ok { + return + } + + r := p.handler.Process(sdb, w.index, w.tx) + p.results[w.index] <- result[R]{ + tx: w.tx.Hash(), + val: &r, + } + } + } +} + +// Close shuts down the [Processor], after which it can no longer be used. +func (p *Processor[R]) Close() { + close(p.work) + p.workers.Wait() +} + +// StartBlock dispatches transactions to the [Handler] and returns immediately. +// It MUST be paired with a call to [Processor.FinishBlock], without overlap of +// blocks. +func (p *Processor[R]) StartBlock(b *types.Block, rules params.Rules, sdb *state.StateDB) error { + p.stateShare.distribute(sdb) + p.handler.BeforeBlock(types.CopyHeader(b.Header())) + txs := b.Transactions() + jobs := make([]*job, 0, len(txs)) + + // We can reuse the channels already in the results slice because they're + // emptied by [Processor.FinishBlock]. + for i, n := len(p.results), len(txs); i < n; i++ { + p.results = append(p.results, make(chan result[R], 1)) + } + + for i, tx := range txs { + switch do, err := p.shouldProcess(tx, rules); { + case err != nil: + return err + + case do: + jobs = append(jobs, &job{ + index: i, + tx: tx, + }) + + default: + p.results[i] <- result[R]{ + tx: tx.Hash(), + val: nil, + } + } + } + + go func() { + // This goroutine is guaranteed to have returned by the time + // [Processor.FinishBlock] does. + for _, j := range jobs { + p.work <- j + } + }() + return nil +} + +// FinishBlock returns the [Processor] to a state ready for the next block. A +// return from FinishBlock guarantees that all dispatched work from the +// respective call to [Processor.StartBlock] has been completed. +func (p *Processor[R]) FinishBlock(b *types.Block) { + for i := range len(b.Transactions()) { + // Every result channel is guaranteed to have some value in its buffer + // because [Processor.BeforeBlock] either sends a nil *R or it + // dispatches a job, which will send a non-nil *R. + tx := (<-p.results[i]).tx + delete(p.txGas, tx) + } +} + +// Result blocks until the i'th transaction passed to [Processor.StartBlock] has +// had its result processed, and then returns the value returned by the +// [Handler]. The returned boolean will be false if no processing occurred, +// either because the [Handler] indicated as such or because the transaction +// supplied insufficient gas. +// +// Multiple calls to Result with the same argument are allowed. Callers MUST NOT +// charge the gas price for preprocessing as this is handled by +// [Processor.PreprocessingGasCharge] if registered as a [vm.Preprocessor]. +// The same value will be returned by each call with the same argument, such +// that if R is a pointer then modifications will persist between calls. +func (p *Processor[R]) Result(i int) (R, bool) { + ch := p.results[i] + r := <-ch + defer func() { + ch <- r + }() + + if r.val == nil { + // TODO(arr4n) if we're here then the implementoor might have a bug in + // their [Handler], so logging a warning is probably a good idea. + var zero R + return zero, false + } + return *r.val, true +} + +func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, retErr error) { + // An explicit 0 is necessary to avoid [Processor.PreprocessingGasCharge] + // returning [ErrTxUnknown]. + p.txGas[tx.Hash()] = 0 + + cost, ok := p.handler.Gas(tx) + if !ok { + return false, nil + } + defer func() { + if process && retErr == nil { + p.txGas[tx.Hash()] = cost + } + }() + + spent, err := txIntrinsicGas(tx, &rules) + if err != nil { + return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) + } + if spent > tx.Gas() { + // If this happens then consensus has a bug because the tx shouldn't + // have been included. We include the check, however, for completeness. + return false, core.ErrIntrinsicGas + } + return tx.Gas()-spent >= cost, nil +} + +func txIntrinsicGas(tx *types.Transaction, rules *params.Rules) (uint64, error) { + return intrinsicGas(tx.Data(), tx.AccessList(), tx.To(), rules) +} + +func intrinsicGas(data []byte, access types.AccessList, txTo *common.Address, rules *params.Rules) (uint64, error) { + create := txTo == nil + return core.IntrinsicGas( + data, + access, + create, + rules.IsHomestead, + rules.IsIstanbul, // EIP-2028 + rules.IsShanghai, // EIP-3860 + ) +} + +// ErrTxUnknown is returned by [Processor.PreprocessingGasCharge] if it is +// called with a transaction hash that wasn't in the last block passed to +// [Processor.StartBlock]. +var ErrTxUnknown = errors.New("transaction unknown by parallel preprocessor") + +// PreprocessingGasCharge implements the [vm.Preprocessor] interface and MUST be +// registered via [vm.RegisterHooks] to ensure proper gas accounting. +func (p *Processor[R]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { + g, ok := p.txGas[tx] + if !ok { + return 0, fmt.Errorf("%w: %v", ErrTxUnknown, tx) + } + return g, nil +} + +var _ vm.Preprocessor = (*Processor[struct{}])(nil) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go new file mode 100644 index 00000000000..dc083da99bd --- /dev/null +++ b/libevm/precompiles/parallel/parallel_test.go @@ -0,0 +1,335 @@ +// 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 parallel + +import ( + "bytes" + "encoding/binary" + "math" + "math/big" + "math/rand/v2" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "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" + "github.com/ava-labs/libevm/trie" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) +} + +type concat struct { + headerExtra []byte + addr common.Address + stateKey common.Hash + gas uint64 +} + +func (c *concat) BeforeBlock(h *types.Header) { + c.headerExtra = slices.Clone(h.Extra) +} + +func (c *concat) Gas(tx *types.Transaction) (uint64, bool) { + if to := tx.To(); to != nil && *to == c.addr { + return c.gas, true + } + return 0, false +} + +func concatOutput(txData []byte, state common.Hash, extra []byte) []byte { + return slices.Concat(txData, state[:], extra) +} + +func (c *concat) Process(sdb libevm.StateReader, i int, tx *types.Transaction) []byte { + return concatOutput( + tx.Data(), + sdb.GetTransientState(c.addr, c.stateKey), + c.headerExtra, + ) +} + +func TestProcessor(t *testing.T) { + handler := &concat{ + addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, + stateKey: common.Hash{'k', 'e', 'y'}, + gas: 1e6, + } + p := New(handler, 8) + t.Cleanup(p.Close) + + type blockParams struct { + numTxs int + sendToAddrEvery, sufficientGasEvery int + } + + // Each set of params is effectively a test case, but they are all run on + // the same [Processor]. + tests := []blockParams{ + { + numTxs: 0, + }, + { + numTxs: 500, + sendToAddrEvery: 7, + sufficientGasEvery: 5, + }, + { + numTxs: 1_000, + sendToAddrEvery: 7, + sufficientGasEvery: 5, + }, + { + numTxs: 1_000, + sendToAddrEvery: 11, + sufficientGasEvery: 3, + }, + { + numTxs: 100, + sendToAddrEvery: 1, + sufficientGasEvery: 1, + }, + { + numTxs: 0, + }, + } + + rng := rand.New(rand.NewPCG(0, 0)) //nolint:gosec // Reproducibility is useful for testing + for range 100 { + tests = append(tests, blockParams{ + numTxs: rng.IntN(1000), + sendToAddrEvery: 1 + rng.IntN(30), + sufficientGasEvery: 1 + rng.IntN(30), + }) + } + + _, _, sdb := ethtest.NewEmptyStateDB(t) + stateVal := common.Hash{'s', 't', 'a', 't', 'e'} + sdb.SetTransientState(handler.addr, handler.stateKey, stateVal) + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + t.Logf("%+v", tt) + + var rules params.Rules + txs := make(types.Transactions, tt.numTxs) + wantProcessed := make([]bool, tt.numTxs) + for i := range len(txs) { + var ( + to common.Address + extraGas uint64 + ) + + wantProcessed[i] = true + if i%tt.sendToAddrEvery == 0 { + to = handler.addr + } else { + wantProcessed[i] = false + } + if i%tt.sufficientGasEvery == 0 { + extraGas = handler.gas + } else { + wantProcessed[i] = false + } + + data := binary.BigEndian.AppendUint64(nil, uint64(i)) + gas, err := intrinsicGas(data, types.AccessList{}, &handler.addr, &rules) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data) + + txs[i] = types.NewTx(&types.LegacyTx{ + To: &to, + Data: data, + Gas: gas + extraGas, + }) + } + + extra := []byte("extra") + block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil)) + require.NoError(t, p.StartBlock(block, rules, sdb), "StartBlock()") + defer p.FinishBlock(block) + + for i, tx := range txs { + wantOK := wantProcessed[i] + + var want []byte + if wantOK { + want = concatOutput(tx.Data(), stateVal, extra) + } + + got, gotOK := p.Result(i) + if !bytes.Equal(got, want) || gotOK != wantOK { + t.Errorf("Result(%d) got (%#x, %t); want (%#x, %t)", i, got, gotOK, want, wantOK) + } + } + }) + + if t.Failed() { + break + } + } +} + +type vmHooks struct { + vm.Preprocessor // the [Processor] + vm.NOOPHooks +} + +func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) { + return h.Preprocessor.PreprocessingGasCharge(tx) +} + +func TestIntegration(t *testing.T) { + const handlerGas = 500 + handler := &concat{ + addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, + gas: handlerGas, + } + sut := New(handler, 8) + t.Cleanup(sut.Close) + + vm.RegisterHooks(&vmHooks{Preprocessor: sut}) + t.Cleanup(vm.TestOnlyClearRegisteredHooks) + + stub := &hookstest.Stub{ + PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{ + handler.addr: vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte) (ret []byte, err error) { + sdb := env.StateDB() + txi, txh := sdb.TxIndex(), sdb.TxHash() + + // Precompiles MUST NOT charge gas for the preprocessing as it + // would then be double-counted. + got, ok := sut.Result(txi) + if !ok { + t.Errorf("no result for tx[%d] %v", txi, txh) + } + sdb.AddLog(&types.Log{ + Data: got[:], + }) + return nil, nil + }), + }, + } + stub.Register(t) + + key, err := crypto.GenerateKey() + require.NoErrorf(t, err, "crypto.GenerateKey()") + eoa := crypto.PubkeyToAddress(key.PublicKey) + + state, evm := ethtest.NewZeroEVM(t) + state.CreateAccount(eoa) + state.SetBalance(eoa, new(uint256.Int).SetAllOne()) + + var ( + txs types.Transactions + want []*types.Receipt + ) + ignore := cmp.Options{ + cmpopts.IgnoreFields( + types.Receipt{}, + "PostState", "CumulativeGasUsed", "BlockNumber", "BlockHash", "Bloom", + ), + cmpopts.IgnoreFields(types.Log{}, "BlockHash"), + } + + header := &types.Header{ + Number: big.NewInt(0), + BaseFee: big.NewInt(0), + } + config := evm.ChainConfig() + rules := config.Rules(header.Number, true, header.Time) + signer := types.MakeSigner(config, header.Number, header.Time) + + for i, addr := range []common.Address{ + {'o', 't', 'h', 'e', 'r'}, + handler.addr, + } { + ui := uint(i) + data := []byte("hello, world") + + gas, err := intrinsicGas(data, types.AccessList{}, &addr, &rules) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data) + if addr == handler.addr { + gas += handlerGas + } + + tx := types.MustSignNewTx(key, signer, &types.LegacyTx{ + Nonce: uint64(ui), + To: &addr, + Data: data, + Gas: gas, + }) + txs = append(txs, tx) + + wantR := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + TxHash: tx.Hash(), + GasUsed: gas, + TransactionIndex: ui, + } + if addr == handler.addr { + wantR.Logs = []*types.Log{{ + TxHash: tx.Hash(), + TxIndex: ui, + Data: concatOutput(data, common.Hash{}, nil), + }} + } + want = append(want, wantR) + } + + block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil)) + require.NoError(t, sut.StartBlock(block, rules, state), "StartBlock()") + defer sut.FinishBlock(block) + + pool := core.GasPool(math.MaxUint64) + var got []*types.Receipt + for i, tx := range txs { + state.SetTxContext(tx.Hash(), i) + + var usedGas uint64 + receipt, err := core.ApplyTransaction( + evm.ChainConfig(), + ethtest.DummyChainContext(), + &block.Header().Coinbase, + &pool, + state, + block.Header(), + tx, + &usedGas, + vm.Config{}, + ) + require.NoError(t, err, "ApplyTransaction([%d])", i) + got = append(got, receipt) + } + + if diff := cmp.Diff(want, got, ignore); diff != "" { + t.Errorf("%T diff (-want +got):\n%s", got, diff) + } +}