From fd542db13cd24bd43530c1b486751b046062ce35 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 13:10:52 +0100 Subject: [PATCH 01/24] feat: `parallel` package for precompile pre-processing --- go.mod | 1 + go.sum | 2 + libevm/precompiles/parallel/parallel.go | 170 +++++++++++++++++++ libevm/precompiles/parallel/parallel_test.go | 148 ++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 libevm/precompiles/parallel/parallel.go create mode 100644 libevm/precompiles/parallel/parallel_test.go diff --git a/go.mod b/go.mod index 7a814eec1ec4..e7dc2f5b1c0b 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 2156147c18f6..87821192c5ef 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/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go new file mode 100644 index 000000000000..026b488ef308 --- /dev/null +++ b/libevm/precompiles/parallel/parallel.go @@ -0,0 +1,170 @@ +package parallel + +import ( + "fmt" + "sync" + + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" +) + +// 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 { + Gas(*types.Transaction) (gas uint64, process bool) + Process(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 *R) +} + +type job struct { + index int + tx *types.Transaction +} + +// 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] { + p := &Processor[R]{ + handler: h, + work: make(chan *job), + } + + workers = max(workers, 1) + p.workers.Add(workers) + for range workers { + go p.worker() + } + return p +} + +func (p *Processor[R]) worker() { + defer p.workers.Done() + for { + w, ok := <-p.work + if !ok { + return + } + + r := p.handler.Process(w.index, w.tx) + p.results[w.index] <- &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) error { + 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 *R, 1)) + } + + for i, tx := range txs { + switch do, err := p.shouldProcess(tx); { + case err != nil: + return err + + case do: + jobs = append(jobs, &job{ + index: i, + tx: tx, + }) + + default: + p.results[i] <- 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 that will send a non-nil *R. + <-p.results[i] + } +} + +// 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. +func (p *Processor[R]) Result(i int) (R, bool) { + ch := p.results[i] + r := <-ch + defer func() { + ch <- r + }() + + if r == 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, true +} + +func (p *Processor[R]) shouldProcess(tx *types.Transaction) (bool, error) { + cost, ok := p.handler.Gas(tx) + if !ok { + return false, nil + } + + spent, err := core.IntrinsicGas( + tx.Data(), + tx.AccessList(), + tx.To() == nil, + true, // Homestead + true, // EIP-2028 (Istanbul) + true, // EIP-3860 (Shanghai) + ) + if err != nil { + return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) + } + + // This could only overflow if the gas limit was insufficient to cover + // the intrinsic cost, which would have invalidated it for inclusion. + left := tx.Gas() - spent + return left >= cost, nil +} diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go new file mode 100644 index 000000000000..9f71d3c7d8b4 --- /dev/null +++ b/libevm/precompiles/parallel/parallel_test.go @@ -0,0 +1,148 @@ +package parallel + +import ( + "crypto/sha256" + "encoding/binary" + "math/rand/v2" + "testing" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/trie" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) +} + +type shaHandler struct { + addr common.Address + gas uint64 +} + +func (h *shaHandler) Gas(tx *types.Transaction) (uint64, bool) { + if to := tx.To(); to == nil || *to != h.addr { + return 0, false + } + return h.gas, true +} + +func (*shaHandler) Process(i int, tx *types.Transaction) [sha256.Size]byte { + return sha256.Sum256(tx.Data()) +} + +func TestProcessor(t *testing.T) { + handler := &shaHandler{ + addr: common.Address{'s', 'h', 'a', 2, 5, 6}, + 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]. + params := []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)) + for range 100 { + params = append(params, blockParams{ + numTxs: rng.IntN(1000), + sendToAddrEvery: 1 + rng.IntN(30), + sufficientGasEvery: 1 + rng.IntN(30), + }) + } + + for _, tc := range params { + t.Run("", func(t *testing.T) { + t.Logf("%+v", tc) + + txs := make(types.Transactions, tc.numTxs) + wantProcessed := make([]bool, tc.numTxs) + for i := range len(txs) { + var ( + to common.Address + extraGas uint64 + ) + + wantProcessed[i] = true + if i%tc.sendToAddrEvery == 0 { + to = handler.addr + } else { + wantProcessed[i] = false + } + if i%tc.sufficientGasEvery == 0 { + extraGas = handler.gas + } else { + wantProcessed[i] = false + } + + data := binary.BigEndian.AppendUint64(nil, uint64(i)) + gas, err := core.IntrinsicGas(data, nil, false, true, true, true) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, true, true, true)", data) + + txs[i] = types.NewTx(&types.LegacyTx{ + To: &to, + Data: data, + Gas: gas + extraGas, + }) + } + + block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) + require.NoError(t, p.StartBlock(block), "BeforeBlock()") + defer p.FinishBlock(block) + + for i, tx := range txs { + wantOK := wantProcessed[i] + + var want [sha256.Size]byte + if wantOK { + want = handler.Process(i, tx) + } + + got, gotOK := p.Result(i) + if got != want || gotOK != wantOK { + t.Errorf("Result(%d) got (%#x, %t); want (%#x, %t)", i, got, gotOK, want, wantOK) + } + } + }) + + if t.Failed() { + break + } + } +} From 3669967032de1e647ac45d7a9388f15d6e568450 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 13:16:58 +0100 Subject: [PATCH 02/24] chore: copyright headers + package comment --- libevm/precompiles/parallel/parallel.go | 18 ++++++++++++++++++ libevm/precompiles/parallel/parallel_test.go | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 026b488ef308..d8ab335afde9 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -1,3 +1,21 @@ +// 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 ( diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 9f71d3c7d8b4..a1b1e949942f 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -1,3 +1,19 @@ +// 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 ( From 9780de0edd00065814ed35c6ec212747b36d8f30 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 13:40:22 +0100 Subject: [PATCH 03/24] chore: placate the linter --- libevm/precompiles/parallel/parallel_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index a1b1e949942f..c50bfb666ed0 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -22,12 +22,13 @@ import ( "math/rand/v2" "testing" + "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/trie" - "github.com/stretchr/testify/require" - "go.uber.org/goleak" ) func TestMain(m *testing.M) { @@ -94,7 +95,7 @@ func TestProcessor(t *testing.T) { }, } - rng := rand.New(rand.NewPCG(0, 0)) + rng := rand.New(rand.NewPCG(0, 0)) //nolint:gosec // Reproducibility is useful for testing for range 100 { params = append(params, blockParams{ numTxs: rng.IntN(1000), From 686cd69fedca0a822379aeaf95f9012b0fb4aaae Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 17:57:26 +0100 Subject: [PATCH 04/24] feat: integration of `parallel.Processor` with `EVM.Call` --- core/vm/evm.go | 2 +- core/vm/evm.libevm.go | 24 +++ core/vm/evm.libevm_test.go | 3 + core/vm/hooks.libevm.go | 9 ++ core/vm/interface.go | 2 + core/vm/interface.libevm.go | 27 ++++ libevm/precompiles/parallel/parallel.go | 52 ++++-- libevm/precompiles/parallel/parallel_test.go | 160 +++++++++++++++++++ 8 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 core/vm/interface.libevm.go diff --git a/core/vm/evm.go b/core/vm/evm.go index b9fd682b9a75..618dd5ec60d4 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 diff --git a/core/vm/evm.libevm.go b/core/vm/evm.libevm.go index c2c807c13784..d9a9ada19aaa 100644 --- a/core/vm/evm.libevm.go +++ b/core/vm/evm.libevm.go @@ -20,6 +20,7 @@ import ( "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/libevm" "github.com/ava-labs/libevm/log" + "github.com/holiman/uint256" ) // canCreateContract is a convenience wrapper for calling the @@ -52,6 +53,29 @@ 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) +} + +func (evm *EVM) spendPreprocessingGas(gas uint64) (uint64, error) { + if evm.depth > 0 || !libevmHooks.Registered() { + return gas, nil + } + c := libevmHooks.Get().PreprocessingGasCharge(evm.StateDB.TxHash()) + 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 c0a33718e3d3..6e3fa290fe9e 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,8 @@ func (o *evmArgOverrider) OverrideEVMResetArgs(r params.Rules, _ *EVMResetArgs) } } +func (o *evmArgOverrider) PreprocessingGasCharge(common.Hash) uint64 { return 0 } + 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 1e5acd49db96..c3cfd1bd9818 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]. +type Preprocessor interface { + PreprocessingGasCharge(tx common.Hash) uint64 } // NewEVMArgs are the arguments received by [NewEVM], available for override diff --git a/core/vm/interface.go b/core/vm/interface.go index 4a9e15a6d3ce..25ef393e863b 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 000000000000..ee999fcc8c58 --- /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/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index d8ab335afde9..5e70f5f98517 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -22,8 +22,10 @@ import ( "fmt" "sync" + "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" ) // A Handler is responsible for processing [types.Transactions] in an @@ -47,7 +49,8 @@ type Processor[R any] struct { handler Handler[R] workers sync.WaitGroup work chan *job - results [](chan *R) + results [](chan result[R]) + txGas map[common.Hash]uint64 } type job struct { @@ -55,6 +58,11 @@ type job struct { 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. @@ -62,6 +70,7 @@ func New[R any](h Handler[R], workers int) *Processor[R] { p := &Processor[R]{ handler: h, work: make(chan *job), + txGas: make(map[common.Hash]uint64), } workers = max(workers, 1) @@ -81,7 +90,10 @@ func (p *Processor[R]) worker() { } r := p.handler.Process(w.index, w.tx) - p.results[w.index] <- &r + p.results[w.index] <- result[R]{ + tx: w.tx.Hash(), + val: &r, + } } } @@ -101,7 +113,7 @@ func (p *Processor[R]) StartBlock(b *types.Block) error { // 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 *R, 1)) + p.results = append(p.results, make(chan result[R], 1)) } for i, tx := range txs { @@ -116,7 +128,10 @@ func (p *Processor[R]) StartBlock(b *types.Block) error { }) default: - p.results[i] <- nil + p.results[i] <- result[R]{ + tx: tx.Hash(), + val: nil, + } } } @@ -138,7 +153,7 @@ func (p *Processor[R]) FinishBlock(b *types.Block) { // 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 that will send a non-nil *R. - <-p.results[i] + delete(p.txGas, (<-p.results[i]).tx) } } @@ -147,27 +162,38 @@ func (p *Processor[R]) FinishBlock(b *types.Block) { // [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 + r := (<-ch) defer func() { ch <- r }() - if r == nil { + 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, true + return *r.val, true } -func (p *Processor[R]) shouldProcess(tx *types.Transaction) (bool, error) { +func (p *Processor[R]) shouldProcess(tx *types.Transaction) (ok bool, err error) { cost, ok := p.handler.Gas(tx) if !ok { return false, nil } + defer func() { + if ok && err == nil { + p.txGas[tx.Hash()] = cost + } + }() spent, err := core.IntrinsicGas( tx.Data(), @@ -186,3 +212,11 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction) (bool, error) { left := tx.Gas() - spent return left >= cost, nil } + +var _ vm.Preprocessor = (*Processor[struct{}])(nil) + +// 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 { + return p.txGas[tx] +} diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index c50bfb666ed0..e535cb1b798d 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -19,15 +19,26 @@ package parallel import ( "crypto/sha256" "encoding/binary" + "math" "math/rand/v2" "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/consensus" "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" ) @@ -163,3 +174,152 @@ func TestProcessor(t *testing.T) { } } } + +type noopHooks struct{} + +func (noopHooks) OverrideNewEVMArgs(a *vm.NewEVMArgs) *vm.NewEVMArgs { + return a +} + +func (noopHooks) OverrideEVMResetArgs(_ params.Rules, a *vm.EVMResetArgs) *vm.EVMResetArgs { + return a +} + +type vmHooks struct { + vm.Preprocessor // the [Processor] + noopHooks +} + +func TestIntegration(t *testing.T) { + const handlerGas = 500 + handler := &shaHandler{ + addr: common.Address{'s', 'h', 'a', 2, 5, 6}, + 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) + } + env.StateDB().AddLog(&types.Log{ + Data: got[:], + }) + return nil, nil + }), + }, + } + stub.Register(t) + + state, evm := ethtest.NewZeroEVM(t) + + key, err := crypto.GenerateKey() + require.NoErrorf(t, err, "crypto.GenerateKey()") + eoa := crypto.PubkeyToAddress(key.PublicKey) + state.CreateAccount(eoa) + state.AddBalance(eoa, uint256.NewInt(10*params.Ether)) + + var ( + txs types.Transactions + want []*types.Receipt + ) + ignore := cmp.Options{ + cmpopts.IgnoreFields( + types.Receipt{}, + "PostState", "CumulativeGasUsed", "BlockNumber", "BlockHash", "Bloom", + ), + cmpopts.IgnoreFields(types.Log{}, "BlockHash"), + } + + signer := types.LatestSigner(evm.ChainConfig()) + for i, addr := range []common.Address{ + {'o', 't', 'h', 'e', 'r'}, + handler.addr, + } { + ui := uint(i) //nolint:gosec // Known value that won't overflow + data := []byte("hello, world") + + // Having all arguments `false` is equivalent to what + // [core.ApplyTransaction] will do. + gas, err := core.IntrinsicGas(data, types.AccessList{}, false, false, false, false) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, false, false, 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 { + res := handler.Process(i, tx) + wantR.Logs = []*types.Log{{ + TxHash: tx.Hash(), + TxIndex: ui, + Data: res[:], + }} + } + want = append(want, wantR) + } + + block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) + require.NoError(t, sut.StartBlock(block), "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(), + chainContext{}, + &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) + } +} + +// Dummy implementations of interfaces required by [core.ApplyTransaction]. +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) { return common.Address{}, nil } From 81c80a8f7e6d20678be19cf90b989af03148ad9b Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 18:08:02 +0100 Subject: [PATCH 05/24] feat: integration of `parallel.Processor` with `EVM.Create` --- core/vm/evm.go | 4 ++-- core/vm/evm.libevm.go | 13 ++++++++++++- core/vm/hooks.libevm.go | 2 +- libevm/precompiles/parallel/parallel_test.go | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/core/vm/evm.go b/core/vm/evm.go index 618dd5ec60d4..b9eca8cfb7f5 100644 --- a/core/vm/evm.go +++ b/core/vm/evm.go @@ -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 d9a9ada19aaa..0bda196c8691 100644 --- a/core/vm/evm.libevm.go +++ b/core/vm/evm.libevm.go @@ -17,10 +17,11 @@ 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" - "github.com/holiman/uint256" ) // canCreateContract is a convenience wrapper for calling the @@ -65,6 +66,16 @@ func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas 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 diff --git a/core/vm/hooks.libevm.go b/core/vm/hooks.libevm.go index c3cfd1bd9818..fbd9be090b2e 100644 --- a/core/vm/hooks.libevm.go +++ b/core/vm/hooks.libevm.go @@ -46,7 +46,7 @@ type Hooks interface { // 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]. +// beginning of [EVM.Call] or [EVM.Create]. type Preprocessor interface { PreprocessingGasCharge(tx common.Hash) uint64 } diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index e535cb1b798d..094163db9b9d 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -248,7 +248,7 @@ func TestIntegration(t *testing.T) { {'o', 't', 'h', 'e', 'r'}, handler.addr, } { - ui := uint(i) //nolint:gosec // Known value that won't overflow + ui := uint(i) data := []byte("hello, world") // Having all arguments `false` is equivalent to what From 04c4cbcb69f494e887cb9e4d089d8fb52e713c2c Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 15 Sep 2025 19:09:40 +0100 Subject: [PATCH 06/24] fix: use `params.Rules` for `core.IntrinsicGas()` args --- libevm/precompiles/parallel/parallel.go | 13 ++++++----- libevm/precompiles/parallel/parallel_test.go | 24 +++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 5e70f5f98517..761d51d9b1a8 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -26,6 +26,7 @@ import ( "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/params" ) // A Handler is responsible for processing [types.Transactions] in an @@ -106,7 +107,7 @@ func (p *Processor[R]) Close() { // 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) error { +func (p *Processor[R]) StartBlock(b *types.Block, rules params.Rules) error { txs := b.Transactions() jobs := make([]*job, 0, len(txs)) @@ -117,7 +118,7 @@ func (p *Processor[R]) StartBlock(b *types.Block) error { } for i, tx := range txs { - switch do, err := p.shouldProcess(tx); { + switch do, err := p.shouldProcess(tx, rules); { case err != nil: return err @@ -184,7 +185,7 @@ func (p *Processor[R]) Result(i int) (R, bool) { return *r.val, true } -func (p *Processor[R]) shouldProcess(tx *types.Transaction) (ok bool, err error) { +func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (ok bool, err error) { cost, ok := p.handler.Gas(tx) if !ok { return false, nil @@ -199,9 +200,9 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction) (ok bool, err error) tx.Data(), tx.AccessList(), tx.To() == nil, - true, // Homestead - true, // EIP-2028 (Istanbul) - true, // EIP-3860 (Shanghai) + rules.IsHomestead, + rules.IsIstanbul, // EIP-2028 + rules.IsShanghai, // EIP-3860 ) if err != nil { return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 094163db9b9d..4c24a73939ae 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -77,7 +77,7 @@ func TestProcessor(t *testing.T) { // Each set of params is effectively a test case, but they are all run on // the same [Processor]. - params := []blockParams{ + tests := []blockParams{ { numTxs: 0, }, @@ -108,19 +108,20 @@ func TestProcessor(t *testing.T) { rng := rand.New(rand.NewPCG(0, 0)) //nolint:gosec // Reproducibility is useful for testing for range 100 { - params = append(params, blockParams{ + tests = append(tests, blockParams{ numTxs: rng.IntN(1000), sendToAddrEvery: 1 + rng.IntN(30), sufficientGasEvery: 1 + rng.IntN(30), }) } - for _, tc := range params { + for _, tt := range tests { t.Run("", func(t *testing.T) { - t.Logf("%+v", tc) + t.Logf("%+v", tt) - txs := make(types.Transactions, tc.numTxs) - wantProcessed := make([]bool, tc.numTxs) + var rules params.Rules + txs := make(types.Transactions, tt.numTxs) + wantProcessed := make([]bool, tt.numTxs) for i := range len(txs) { var ( to common.Address @@ -128,19 +129,19 @@ func TestProcessor(t *testing.T) { ) wantProcessed[i] = true - if i%tc.sendToAddrEvery == 0 { + if i%tt.sendToAddrEvery == 0 { to = handler.addr } else { wantProcessed[i] = false } - if i%tc.sufficientGasEvery == 0 { + if i%tt.sufficientGasEvery == 0 { extraGas = handler.gas } else { wantProcessed[i] = false } data := binary.BigEndian.AppendUint64(nil, uint64(i)) - gas, err := core.IntrinsicGas(data, nil, false, true, true, true) + gas, err := core.IntrinsicGas(data, nil, false, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai) require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, true, true, true)", data) txs[i] = types.NewTx(&types.LegacyTx{ @@ -151,7 +152,7 @@ func TestProcessor(t *testing.T) { } block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) - require.NoError(t, p.StartBlock(block), "BeforeBlock()") + require.NoError(t, p.StartBlock(block, rules), "StartBlock()") defer p.FinishBlock(block) for i, tx := range txs { @@ -285,7 +286,8 @@ func TestIntegration(t *testing.T) { } block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) - require.NoError(t, sut.StartBlock(block), "StartBlock()") + rules := evm.ChainConfig().Rules(block.Number(), true, block.Time()) + require.NoError(t, sut.StartBlock(block, rules), "StartBlock()") defer sut.FinishBlock(block) pool := core.GasPool(math.MaxUint64) From e26b0246815c7cc4ed99ae20a6352a92faa748c8 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 16 Sep 2025 03:54:38 +0100 Subject: [PATCH 07/24] feat: `Processor.PreprocessingGasCharge` errors on unknown tx --- core/vm/evm.libevm.go | 5 ++++- core/vm/evm.libevm_test.go | 4 +++- core/vm/hooks.libevm.go | 2 +- libevm/precompiles/parallel/parallel.go | 29 +++++++++++++++++++------ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/core/vm/evm.libevm.go b/core/vm/evm.libevm.go index 0bda196c8691..0e902bec4275 100644 --- a/core/vm/evm.libevm.go +++ b/core/vm/evm.libevm.go @@ -80,7 +80,10 @@ func (evm *EVM) spendPreprocessingGas(gas uint64) (uint64, error) { if evm.depth > 0 || !libevmHooks.Registered() { return gas, nil } - c := libevmHooks.Get().PreprocessingGasCharge(evm.StateDB.TxHash()) + c, err := libevmHooks.Get().PreprocessingGasCharge(evm.StateDB.TxHash()) + if err != nil { + return gas, err + } if c > gas { return 0, ErrOutOfGas } diff --git a/core/vm/evm.libevm_test.go b/core/vm/evm.libevm_test.go index 6e3fa290fe9e..deb7a0c67707 100644 --- a/core/vm/evm.libevm_test.go +++ b/core/vm/evm.libevm_test.go @@ -47,7 +47,9 @@ func (o *evmArgOverrider) OverrideEVMResetArgs(r params.Rules, _ *EVMResetArgs) } } -func (o *evmArgOverrider) PreprocessingGasCharge(common.Hash) uint64 { return 0 } +func (o *evmArgOverrider) PreprocessingGasCharge(common.Hash) (uint64, error) { + return 0, nil +} func (o *evmArgOverrider) register(t *testing.T) { t.Helper() diff --git a/core/vm/hooks.libevm.go b/core/vm/hooks.libevm.go index fbd9be090b2e..cef960dfe28f 100644 --- a/core/vm/hooks.libevm.go +++ b/core/vm/hooks.libevm.go @@ -48,7 +48,7 @@ type Hooks interface { // [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 + PreprocessingGasCharge(tx common.Hash) (uint64, error) } // NewEVMArgs are the arguments received by [NewEVM], available for override diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 761d51d9b1a8..f12f73620449 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -19,6 +19,7 @@ package parallel import ( + "errors" "fmt" "sync" @@ -154,7 +155,8 @@ func (p *Processor[R]) FinishBlock(b *types.Block) { // 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 that will send a non-nil *R. - delete(p.txGas, (<-p.results[i]).tx) + tx := (<-p.results[i]).tx + delete(p.txGas, tx) } } @@ -171,7 +173,7 @@ func (p *Processor[R]) FinishBlock(b *types.Block) { // 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) + r := <-ch defer func() { ch <- r }() @@ -185,13 +187,17 @@ func (p *Processor[R]) Result(i int) (R, bool) { return *r.val, true } -func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (ok bool, err error) { +func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, err 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 ok && err == nil { + if process && err == nil { p.txGas[tx.Hash()] = cost } }() @@ -214,10 +220,19 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) return left >= cost, nil } -var _ vm.Preprocessor = (*Processor[struct{}])(nil) +// 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 { - return p.txGas[tx] +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) From 53f6df3ab71074e3444cb8167aa65d43d729f83c Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 23 Sep 2025 09:53:53 +0100 Subject: [PATCH 08/24] feat: `Handler.BeforeBlock()` --- libevm/precompiles/parallel/parallel.go | 27 +++++++--- libevm/precompiles/parallel/parallel_test.go | 53 +++++++++++++------- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index f12f73620449..3f0e3f29a9d4 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -42,6 +42,7 @@ import ( // 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(index int, tx *types.Transaction) Result } @@ -109,6 +110,7 @@ func (p *Processor[R]) Close() { // 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) error { + p.handler.BeforeBlock(types.CopyHeader(b.Header())) txs := b.Transactions() jobs := make([]*job, 0, len(txs)) @@ -202,14 +204,7 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) } }() - spent, err := core.IntrinsicGas( - tx.Data(), - tx.AccessList(), - tx.To() == nil, - rules.IsHomestead, - rules.IsIstanbul, // EIP-2028 - rules.IsShanghai, // EIP-3860 - ) + spent, err := txIntrinsicGas(tx, &rules) if err != nil { return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) } @@ -220,6 +215,22 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) return left >= 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]. diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 4c24a73939ae..f12c9bfcfd37 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -17,10 +17,12 @@ package parallel import ( - "crypto/sha256" + "bytes" "encoding/binary" "math" + "math/big" "math/rand/v2" + "slices" "testing" "github.com/google/go-cmp/cmp" @@ -46,25 +48,36 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) } -type shaHandler struct { - addr common.Address - gas uint64 +type reverser struct { + extra []byte + addr common.Address + gas uint64 } -func (h *shaHandler) Gas(tx *types.Transaction) (uint64, bool) { - if to := tx.To(); to == nil || *to != h.addr { +func (r *reverser) BeforeBlock(h *types.Header) { + r.extra = h.Extra +} + +func (r *reverser) Gas(tx *types.Transaction) (uint64, bool) { + if to := tx.To(); to == nil || *to != r.addr { return 0, false } - return h.gas, true + return r.gas, true +} + +func reverserOutput(data, extra []byte) []byte { + out := append(data, extra...) + slices.Reverse(out) + return out } -func (*shaHandler) Process(i int, tx *types.Transaction) [sha256.Size]byte { - return sha256.Sum256(tx.Data()) +func (r *reverser) Process(i int, tx *types.Transaction) []byte { + return reverserOutput(tx.Data(), r.extra) } func TestProcessor(t *testing.T) { - handler := &shaHandler{ - addr: common.Address{'s', 'h', 'a', 2, 5, 6}, + handler := &reverser{ + addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, gas: 1e6, } p := New(handler, 8) @@ -141,7 +154,7 @@ func TestProcessor(t *testing.T) { } data := binary.BigEndian.AppendUint64(nil, uint64(i)) - gas, err := core.IntrinsicGas(data, nil, false, rules.IsHomestead, rules.IsIstanbul, rules.IsShanghai) + gas, err := intrinsicGas(data, types.AccessList{}, &handler.addr, &rules) require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, true, true, true)", data) txs[i] = types.NewTx(&types.LegacyTx{ @@ -151,20 +164,21 @@ func TestProcessor(t *testing.T) { }) } - block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) + extra := []byte("extra") + block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil)) require.NoError(t, p.StartBlock(block, rules), "StartBlock()") defer p.FinishBlock(block) for i, tx := range txs { wantOK := wantProcessed[i] - var want [sha256.Size]byte + var want []byte if wantOK { - want = handler.Process(i, tx) + want = reverserOutput(tx.Data(), extra) } got, gotOK := p.Result(i) - if got != want || gotOK != wantOK { + if !bytes.Equal(got, want) || gotOK != wantOK { t.Errorf("Result(%d) got (%#x, %t); want (%#x, %t)", i, got, gotOK, want, wantOK) } } @@ -193,8 +207,8 @@ type vmHooks struct { func TestIntegration(t *testing.T) { const handlerGas = 500 - handler := &shaHandler{ - addr: common.Address{'s', 'h', 'a', 2, 5, 6}, + handler := &reverser{ + addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, gas: handlerGas, } sut := New(handler, 8) @@ -254,7 +268,8 @@ func TestIntegration(t *testing.T) { // Having all arguments `false` is equivalent to what // [core.ApplyTransaction] will do. - gas, err := core.IntrinsicGas(data, types.AccessList{}, false, false, false, false) + rules := evm.ChainConfig().Rules(big.NewInt(0), false, 0) + gas, err := intrinsicGas(data, types.AccessList{}, &addr, &rules) require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, false, false, false)", data) if addr == handler.addr { gas += handlerGas From 9788f8b9f7c4a1b1f7f158d2951997fcb10ca312 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 23 Sep 2025 12:23:36 +0100 Subject: [PATCH 09/24] test(vm): preprocessing gas charges --- core/vm/hooks.libevm.go | 16 ++ core/vm/preprocess.libevm_test.go | 180 +++++++++++++++++++ libevm/ethtest/dummy.go | 44 +++++ libevm/precompiles/parallel/parallel_test.go | 31 +--- 4 files changed, 247 insertions(+), 24 deletions(-) create mode 100644 core/vm/preprocess.libevm_test.go create mode 100644 libevm/ethtest/dummy.go diff --git a/core/vm/hooks.libevm.go b/core/vm/hooks.libevm.go index cef960dfe28f..a0ef69ba8114 100644 --- a/core/vm/hooks.libevm.go +++ b/core/vm/hooks.libevm.go @@ -89,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/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go new file mode 100644 index 000000000000..ee9463f641a0 --- /dev/null +++ b/core/vm/preprocess.libevm_test.go @@ -0,0 +1,180 @@ +// 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 ( + "fmt" + "math" + "math/big" + "testing" + + "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/crypto" + "github.com/ava-labs/libevm/libevm/ethtest" + "github.com/ava-labs/libevm/params" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type preprocessingCharger struct { + vm.NOOPHooks + charge map[common.Hash]uint64 +} + +func (p preprocessingCharger) PreprocessingGasCharge(tx common.Hash) (uint64, error) { + c, ok := p.charge[tx] + if !ok { + return 0, fmt.Errorf("unknown tx %v", tx) + } + return c, nil +} + +func TestChargePreprocessingGas(t *testing.T) { + tests := []struct { + name string + to *common.Address + charge uint64 + 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, + }, + } + + 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 _, tt := range tests { + tx := types.MustSignNewTx(key, signer, &types.LegacyTx{ + To: tt.to, + GasPrice: big.NewInt(1), + Gas: tt.txGas, + }) + txs = append(txs, tx) + 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, err := state.New( + types.EmptyRootHash, + state.NewDatabase(rawdb.NewMemoryDatabase()), + nil, + ) + require.NoError(t, err, "state.New(types.EmptyRootHash, [memory db], nil)") + sdb.SetTxContext(tx.Hash(), i) + sdb.SetBalance(eoa, new(uint256.Int).SetAllOne()) + + 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.SetBalance(eoa, new(uint256.Int).SetAllOne()) + sdb.SetTxContext(tx.Hash(), i) + + 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 000000000000..e800a513d27f --- /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/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index f12c9bfcfd37..a244093bcf1c 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -32,7 +32,6 @@ import ( "go.uber.org/goleak" "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" "github.com/ava-labs/libevm/core/vm" @@ -190,19 +189,13 @@ func TestProcessor(t *testing.T) { } } -type noopHooks struct{} - -func (noopHooks) OverrideNewEVMArgs(a *vm.NewEVMArgs) *vm.NewEVMArgs { - return a -} - -func (noopHooks) OverrideEVMResetArgs(_ params.Rules, a *vm.EVMResetArgs) *vm.EVMResetArgs { - return a -} - type vmHooks struct { vm.Preprocessor // the [Processor] - noopHooks + vm.NOOPHooks +} + +func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) { + return h.Preprocessor.PreprocessingGasCharge(tx) } func TestIntegration(t *testing.T) { @@ -214,7 +207,7 @@ func TestIntegration(t *testing.T) { sut := New(handler, 8) t.Cleanup(sut.Close) - vm.RegisterHooks(vmHooks{Preprocessor: sut}) + vm.RegisterHooks(&vmHooks{Preprocessor: sut}) t.Cleanup(vm.TestOnlyClearRegisteredHooks) stub := &hookstest.Stub{ @@ -313,7 +306,7 @@ func TestIntegration(t *testing.T) { var usedGas uint64 receipt, err := core.ApplyTransaction( evm.ChainConfig(), - chainContext{}, + ethtest.DummyChainContext(), &block.Header().Coinbase, &pool, state, @@ -330,13 +323,3 @@ func TestIntegration(t *testing.T) { t.Errorf("%T diff (-want +got):\n%s", got, diff) } } - -// Dummy implementations of interfaces required by [core.ApplyTransaction]. -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) { return common.Address{}, nil } From 9b26ef3157f659601b49787cb3eb76c67fc31104 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 23 Sep 2025 12:32:41 +0100 Subject: [PATCH 10/24] test(vm): error propagation from preprocessing gas charge --- core/vm/preprocess.libevm_test.go | 39 +++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/core/vm/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go index ee9463f641a0..a45a2e84ce96 100644 --- a/core/vm/preprocess.libevm_test.go +++ b/core/vm/preprocess.libevm_test.go @@ -17,6 +17,7 @@ package vm_test import ( + "errors" "fmt" "math" "math/big" @@ -41,22 +42,25 @@ type preprocessingCharger struct { 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("unknown tx %v", tx) + 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 - txGas uint64 - wantVMErr error - wantGasUsed uint64 + name string + to *common.Address + charge uint64 + skipChargeRegistration bool + txGas uint64 + wantVMErr error + wantGasUsed uint64 }{ { name: "standard create", @@ -92,6 +96,14 @@ func TestChargePreprocessingGas(t *testing.T) { 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 @@ -108,14 +120,19 @@ func TestChargePreprocessingGas(t *testing.T) { var txs types.Transactions charge := make(map[common.Hash]uint64) - for _, tt := range tests { + 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), //nolint:gosec // Known to not overflow To: tt.to, GasPrice: big.NewInt(1), Gas: tt.txGas, }) txs = append(txs, tx) - charge[tx.Hash()] = tt.charge + if !tt.skipChargeRegistration { + charge[tx.Hash()] = tt.charge + } } vm.RegisterHooks(&preprocessingCharger{ @@ -138,6 +155,7 @@ func TestChargePreprocessingGas(t *testing.T) { require.NoError(t, err, "state.New(types.EmptyRootHash, [memory db], nil)") 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) @@ -164,8 +182,9 @@ func TestChargePreprocessingGas(t *testing.T) { t.Run("VM_error", func(t *testing.T) { sdb, evm := ethtest.NewZeroEVM(t, ethtest.WithChainConfig(config)) - sdb.SetBalance(eoa, new(uint256.Int).SetAllOne()) 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(...)") From f7ff38dccc22bb311884738eac5986478f71aec1 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 23 Sep 2025 12:34:00 +0100 Subject: [PATCH 11/24] chore: placate the linter --- core/vm/preprocess.libevm_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/vm/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go index a45a2e84ce96..890e007e9e5e 100644 --- a/core/vm/preprocess.libevm_test.go +++ b/core/vm/preprocess.libevm_test.go @@ -23,6 +23,10 @@ import ( "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/rawdb" @@ -32,9 +36,6 @@ import ( "github.com/ava-labs/libevm/crypto" "github.com/ava-labs/libevm/libevm/ethtest" "github.com/ava-labs/libevm/params" - "github.com/holiman/uint256" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) type preprocessingCharger struct { From 031d4ff026bca2a750bb11391897cfbdfa4d91e3 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 23 Sep 2025 15:26:28 +0100 Subject: [PATCH 12/24] fix: clone tx data in test handler --- core/vm/preprocess.libevm_test.go | 2 +- libevm/precompiles/parallel/parallel_test.go | 25 +++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/core/vm/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go index 890e007e9e5e..e361205b2c6d 100644 --- a/core/vm/preprocess.libevm_test.go +++ b/core/vm/preprocess.libevm_test.go @@ -125,7 +125,7 @@ func TestChargePreprocessingGas(t *testing.T) { 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), //nolint:gosec // Known to not overflow + Nonce: uint64(i), To: tt.to, GasPrice: big.NewInt(1), Gas: tt.txGas, diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index a244093bcf1c..10b220c2e7ac 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -65,7 +65,7 @@ func (r *reverser) Gas(tx *types.Transaction) (uint64, bool) { } func reverserOutput(data, extra []byte) []byte { - out := append(data, extra...) + out := append(slices.Clone(data), extra...) slices.Reverse(out) return out } @@ -222,7 +222,7 @@ func TestIntegration(t *testing.T) { if !ok { t.Errorf("no result for tx[%d] %v", txi, txh) } - env.StateDB().AddLog(&types.Log{ + sdb.AddLog(&types.Log{ Data: got[:], }) return nil, nil @@ -231,13 +231,13 @@ func TestIntegration(t *testing.T) { } stub.Register(t) - state, evm := ethtest.NewZeroEVM(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.AddBalance(eoa, uint256.NewInt(10*params.Ether)) + state.SetBalance(eoa, new(uint256.Int).SetAllOne()) var ( txs types.Transactions @@ -251,7 +251,14 @@ func TestIntegration(t *testing.T) { cmpopts.IgnoreFields(types.Log{}, "BlockHash"), } - signer := types.LatestSigner(evm.ChainConfig()) + 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, @@ -259,9 +266,6 @@ func TestIntegration(t *testing.T) { ui := uint(i) data := []byte("hello, world") - // Having all arguments `false` is equivalent to what - // [core.ApplyTransaction] will do. - rules := evm.ChainConfig().Rules(big.NewInt(0), false, 0) gas, err := intrinsicGas(data, types.AccessList{}, &addr, &rules) require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, false, false, false)", data) if addr == handler.addr { @@ -293,8 +297,7 @@ func TestIntegration(t *testing.T) { want = append(want, wantR) } - block := types.NewBlock(&types.Header{}, txs, nil, nil, trie.NewStackTrie(nil)) - rules := evm.ChainConfig().Rules(block.Number(), true, block.Time()) + block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil)) require.NoError(t, sut.StartBlock(block, rules), "StartBlock()") defer sut.FinishBlock(block) From 5ca8376cb70bc41013c1cae6cf63063eacb070ea Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 24 Sep 2025 12:36:32 +0100 Subject: [PATCH 13/24] feat: read-only state access --- libevm/ethtest/evm.go | 19 ++++- libevm/precompiles/parallel/parallel.go | 88 ++++++++++++++++---- libevm/precompiles/parallel/parallel_test.go | 11 +-- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/libevm/ethtest/evm.go b/libevm/ethtest/evm.go index 4e16c4e90bb2..7a7b463295e2 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 index 3f0e3f29a9d4..6227c3d0dd8c 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -25,8 +25,10 @@ import ( "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" ) @@ -44,16 +46,17 @@ import ( type Handler[Result any] interface { BeforeBlock(*types.Header) Gas(*types.Transaction) (gas uint64, process bool) - Process(index int, tx *types.Transaction) Result + Process(index int, tx *types.Transaction, sdb libevm.StateReader) 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 + handler Handler[R] + workers sync.WaitGroup + work chan *job + results [](chan result[R]) + txGas map[common.Hash]uint64 + stateShare stateDBSharer } type job struct { @@ -66,36 +69,75 @@ type result[T any] struct { val *T } +// 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 +} + // 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{}), + }, } - workers = max(workers, 1) - p.workers.Add(workers) + 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 } 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 { - w, ok := <-p.work - if !ok { - return - } + select { + case <-stateAvailable: // guaranteed at the beginning of each block + share.mu.Lock() + sdb = share.primary.Copy() + share.mu.Unlock() - r := p.handler.Process(w.index, w.tx) - p.results[w.index] <- result[R]{ - tx: w.tx.Hash(), - val: &r, + stateAvailable = share.nextAvailable + share.wg.Done() + + case w, ok := <-p.work: + if !ok { + return + } + + r := p.handler.Process(w.index, w.tx, sdb) + p.results[w.index] <- result[R]{ + tx: w.tx.Hash(), + val: &r, + } } } } @@ -109,7 +151,8 @@ func (p *Processor[R]) Close() { // 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) error { +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)) @@ -149,6 +192,17 @@ func (p *Processor[R]) StartBlock(b *types.Block, rules params.Rules) error { return nil } +func (s *stateDBSharer) distribute(sdb *state.StateDB) { + s.primary = sdb // no need to Copy() as each worker does it + + ch := s.nextAvailable + s.nextAvailable = make(chan struct{}) // already copied by each worker + + s.wg.Add(s.workers) + close(ch) + s.wg.Wait() +} + // 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. diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 10b220c2e7ac..b8931c525e30 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -70,7 +70,7 @@ func reverserOutput(data, extra []byte) []byte { return out } -func (r *reverser) Process(i int, tx *types.Transaction) []byte { +func (r *reverser) Process(i int, tx *types.Transaction, _ libevm.StateReader) []byte { return reverserOutput(tx.Data(), r.extra) } @@ -127,6 +127,8 @@ func TestProcessor(t *testing.T) { }) } + _, _, sdb := ethtest.NewEmptyStateDB(t) + for _, tt := range tests { t.Run("", func(t *testing.T) { t.Logf("%+v", tt) @@ -165,7 +167,7 @@ func TestProcessor(t *testing.T) { extra := []byte("extra") block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil)) - require.NoError(t, p.StartBlock(block, rules), "StartBlock()") + require.NoError(t, p.StartBlock(block, rules, sdb), "StartBlock()") defer p.FinishBlock(block) for i, tx := range txs { @@ -287,18 +289,17 @@ func TestIntegration(t *testing.T) { TransactionIndex: ui, } if addr == handler.addr { - res := handler.Process(i, tx) wantR.Logs = []*types.Log{{ TxHash: tx.Hash(), TxIndex: ui, - Data: res[:], + Data: reverserOutput(data, nil), }} } want = append(want, wantR) } block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil)) - require.NoError(t, sut.StartBlock(block, rules), "StartBlock()") + require.NoError(t, sut.StartBlock(block, rules, state), "StartBlock()") defer sut.FinishBlock(block) pool := core.GasPool(math.MaxUint64) From 63e8dcd25d0507d043f7e79443de9ae68bf8b6ed Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 24 Sep 2025 12:47:42 +0100 Subject: [PATCH 14/24] test: `StateDB` propagation --- libevm/precompiles/parallel/parallel.go | 4 +-- libevm/precompiles/parallel/parallel_test.go | 32 ++++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 6227c3d0dd8c..8ba1760f2a9e 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -46,7 +46,7 @@ import ( type Handler[Result any] interface { BeforeBlock(*types.Header) Gas(*types.Transaction) (gas uint64, process bool) - Process(index int, tx *types.Transaction, sdb libevm.StateReader) Result + Process(sdb libevm.StateReader, index int, tx *types.Transaction) Result } // A Processor orchestrates dispatch and collection of results from a [Handler]. @@ -133,7 +133,7 @@ func (p *Processor[R]) worker() { return } - r := p.handler.Process(w.index, w.tx, sdb) + r := p.handler.Process(sdb, w.index, w.tx) p.results[w.index] <- result[R]{ tx: w.tx.Hash(), val: &r, diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index b8931c525e30..0ba4e11335cd 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -48,13 +48,14 @@ func TestMain(m *testing.M) { } type reverser struct { - extra []byte - addr common.Address - gas uint64 + headerExtra []byte + addr common.Address + stateKey common.Hash + gas uint64 } func (r *reverser) BeforeBlock(h *types.Header) { - r.extra = h.Extra + r.headerExtra = slices.Clone(h.Extra) } func (r *reverser) Gas(tx *types.Transaction) (uint64, bool) { @@ -64,20 +65,25 @@ func (r *reverser) Gas(tx *types.Transaction) (uint64, bool) { return r.gas, true } -func reverserOutput(data, extra []byte) []byte { - out := append(slices.Clone(data), extra...) +func reverserOutput(txData []byte, state common.Hash, extra []byte) []byte { + out := slices.Concat(txData, state[:], extra) slices.Reverse(out) return out } -func (r *reverser) Process(i int, tx *types.Transaction, _ libevm.StateReader) []byte { - return reverserOutput(tx.Data(), r.extra) +func (r *reverser) Process(sdb libevm.StateReader, i int, tx *types.Transaction) []byte { + return reverserOutput( + tx.Data(), + sdb.GetTransientState(r.addr, r.stateKey), + r.headerExtra, + ) } func TestProcessor(t *testing.T) { handler := &reverser{ - addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, - gas: 1e6, + addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, + stateKey: common.Hash{'k', 'e', 'y'}, + gas: 1e6, } p := New(handler, 8) t.Cleanup(p.Close) @@ -128,6 +134,8 @@ func TestProcessor(t *testing.T) { } _, _, 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) { @@ -175,7 +183,7 @@ func TestProcessor(t *testing.T) { var want []byte if wantOK { - want = reverserOutput(tx.Data(), extra) + want = reverserOutput(tx.Data(), stateVal, extra) } got, gotOK := p.Result(i) @@ -292,7 +300,7 @@ func TestIntegration(t *testing.T) { wantR.Logs = []*types.Log{{ TxHash: tx.Hash(), TxIndex: ui, - Data: reverserOutput(data, nil), + Data: reverserOutput(data, common.Hash{}, nil), }} } want = append(want, wantR) From 866bb86448e7e146707899825184007c49d6b8d0 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 29 Sep 2025 12:07:24 +0100 Subject: [PATCH 15/24] refactor: readability improvements --- core/vm/preprocess.libevm_test.go | 9 +-- libevm/precompiles/parallel/parallel.go | 63 ++++++++++---------- libevm/precompiles/parallel/parallel_test.go | 44 +++++++------- 3 files changed, 54 insertions(+), 62 deletions(-) diff --git a/core/vm/preprocess.libevm_test.go b/core/vm/preprocess.libevm_test.go index e361205b2c6d..509682668c09 100644 --- a/core/vm/preprocess.libevm_test.go +++ b/core/vm/preprocess.libevm_test.go @@ -29,8 +29,6 @@ import ( "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/crypto" @@ -148,12 +146,7 @@ func TestChargePreprocessingGas(t *testing.T) { t.Logf("Extra gas charge: %d", tt.charge) t.Run("ApplyTransaction", func(t *testing.T) { - sdb, err := state.New( - types.EmptyRootHash, - state.NewDatabase(rawdb.NewMemoryDatabase()), - nil, - ) - require.NoError(t, err, "state.New(types.EmptyRootHash, [memory db], nil)") + _, _, sdb := ethtest.NewEmptyStateDB(t) sdb.SetTxContext(tx.Hash(), i) sdb.SetBalance(eoa, new(uint256.Int).SetAllOne()) sdb.SetNonce(eoa, tx.Nonce()) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 8ba1760f2a9e..4caca882898b 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -69,18 +69,6 @@ type result[T any] struct { val *T } -// 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 -} - // 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. @@ -107,6 +95,29 @@ func New[R any](h Handler[R], workers int) *Processor[R] { 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() @@ -192,17 +203,6 @@ func (p *Processor[R]) StartBlock(b *types.Block, rules params.Rules, sdb *state return nil } -func (s *stateDBSharer) distribute(sdb *state.StateDB) { - s.primary = sdb // no need to Copy() as each worker does it - - ch := s.nextAvailable - s.nextAvailable = make(chan struct{}) // already copied by each worker - - s.wg.Add(s.workers) - close(ch) - s.wg.Wait() -} - // 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. @@ -210,7 +210,7 @@ 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 that will send a non-nil *R. + // dispatches a job, which will send a non-nil *R. tx := (<-p.results[i]).tx delete(p.txGas, tx) } @@ -243,7 +243,7 @@ func (p *Processor[R]) Result(i int) (R, bool) { return *r.val, true } -func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, err error) { +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 @@ -253,7 +253,7 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) return false, nil } defer func() { - if process && err == nil { + if process && retErr == nil { p.txGas[tx.Hash()] = cost } }() @@ -262,11 +262,12 @@ func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) if err != nil { return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) } - - // This could only overflow if the gas limit was insufficient to cover - // the intrinsic cost, which would have invalidated it for inclusion. - left := tx.Gas() - spent - return left >= cost, nil + 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) { diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 0ba4e11335cd..dc083da99bd0 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -47,41 +47,39 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) } -type reverser struct { +type concat struct { headerExtra []byte addr common.Address stateKey common.Hash gas uint64 } -func (r *reverser) BeforeBlock(h *types.Header) { - r.headerExtra = slices.Clone(h.Extra) +func (c *concat) BeforeBlock(h *types.Header) { + c.headerExtra = slices.Clone(h.Extra) } -func (r *reverser) Gas(tx *types.Transaction) (uint64, bool) { - if to := tx.To(); to == nil || *to != r.addr { - return 0, false +func (c *concat) Gas(tx *types.Transaction) (uint64, bool) { + if to := tx.To(); to != nil && *to == c.addr { + return c.gas, true } - return r.gas, true + return 0, false } -func reverserOutput(txData []byte, state common.Hash, extra []byte) []byte { - out := slices.Concat(txData, state[:], extra) - slices.Reverse(out) - return out +func concatOutput(txData []byte, state common.Hash, extra []byte) []byte { + return slices.Concat(txData, state[:], extra) } -func (r *reverser) Process(sdb libevm.StateReader, i int, tx *types.Transaction) []byte { - return reverserOutput( +func (c *concat) Process(sdb libevm.StateReader, i int, tx *types.Transaction) []byte { + return concatOutput( tx.Data(), - sdb.GetTransientState(r.addr, r.stateKey), - r.headerExtra, + sdb.GetTransientState(c.addr, c.stateKey), + c.headerExtra, ) } func TestProcessor(t *testing.T) { - handler := &reverser{ - addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, + handler := &concat{ + addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, stateKey: common.Hash{'k', 'e', 'y'}, gas: 1e6, } @@ -164,7 +162,7 @@ func TestProcessor(t *testing.T) { 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, true, true, true)", data) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data) txs[i] = types.NewTx(&types.LegacyTx{ To: &to, @@ -183,7 +181,7 @@ func TestProcessor(t *testing.T) { var want []byte if wantOK { - want = reverserOutput(tx.Data(), stateVal, extra) + want = concatOutput(tx.Data(), stateVal, extra) } got, gotOK := p.Result(i) @@ -210,8 +208,8 @@ func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) { func TestIntegration(t *testing.T) { const handlerGas = 500 - handler := &reverser{ - addr: common.Address{'r', 'e', 'v', 'e', 'r', 's', 'e'}, + handler := &concat{ + addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, gas: handlerGas, } sut := New(handler, 8) @@ -277,7 +275,7 @@ func TestIntegration(t *testing.T) { data := []byte("hello, world") gas, err := intrinsicGas(data, types.AccessList{}, &addr, &rules) - require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, false, false, false)", data) + require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data) if addr == handler.addr { gas += handlerGas } @@ -300,7 +298,7 @@ func TestIntegration(t *testing.T) { wantR.Logs = []*types.Log{{ TxHash: tx.Hash(), TxIndex: ui, - Data: reverserOutput(data, common.Hash{}, nil), + Data: concatOutput(data, common.Hash{}, nil), }} } want = append(want, wantR) From 32a101a9e1e03b0faec1126fcecce6290d8480ad Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 29 Oct 2025 20:52:27 +0000 Subject: [PATCH 16/24] feat: before- and after-block hooks with additional arguments --- libevm/precompiles/parallel/parallel.go | 32 +++++++++++++++++--- libevm/precompiles/parallel/parallel_test.go | 14 +++++---- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 4caca882898b..cab218851695 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -29,6 +29,7 @@ import ( "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/libevm/stateconf" "github.com/ava-labs/libevm/params" ) @@ -43,10 +44,22 @@ import ( // // Scenario (2) allows precompile access to be determined through inspection of // the [types.Transaction] alone, without the need for execution. +// +// All [libevm.StateReader] instances are opened to the state at the beginning +// of the block. The [StateDB] is the same one used to execute the block, +// before being committed, and MAY be written to. type Handler[Result any] interface { - BeforeBlock(*types.Header) + BeforeBlock(libevm.StateReader, *types.Block) Gas(*types.Transaction) (gas uint64, process bool) Process(sdb libevm.StateReader, index int, tx *types.Transaction) Result + AfterBlock(StateDB, *types.Block, types.Receipts) +} + +// StateDB is the subset of [state.StateDB] methods that MAY be called by +// [Handler.AfterBlock]. +type StateDB interface { + libevm.StateReader + SetState(_ common.Address, key, val common.Hash, _ ...stateconf.StateDBStateOption) } // A Processor orchestrates dispatch and collection of results from a [Handler]. @@ -162,9 +175,19 @@ func (p *Processor[R]) Close() { // 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 { +func (p *Processor[R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { + // The distribution mechanism copies the StateDB so we don't need to do it + // here, but the [Handler] is called directly so we do copy. p.stateShare.distribute(sdb) - p.handler.BeforeBlock(types.CopyHeader(b.Header())) + p.handler.BeforeBlock( + sdb.Copy(), + types.NewBlockWithHeader( + b.Header(), + ).WithBody( + *b.Body(), + ), + ) + txs := b.Transactions() jobs := make([]*job, 0, len(txs)) @@ -206,7 +229,7 @@ func (p *Processor[R]) StartBlock(b *types.Block, rules params.Rules, sdb *state // 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) { +func (p *Processor[R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { 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 @@ -214,6 +237,7 @@ func (p *Processor[R]) FinishBlock(b *types.Block) { tx := (<-p.results[i]).tx delete(p.txGas, tx) } + p.handler.AfterBlock(sdb, b, rs) } // Result blocks until the i'th transaction passed to [Processor.StartBlock] has diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index dc083da99bd0..7ed2589c4f53 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -54,8 +54,8 @@ type concat struct { gas uint64 } -func (c *concat) BeforeBlock(h *types.Header) { - c.headerExtra = slices.Clone(h.Extra) +func (c *concat) BeforeBlock(_ libevm.StateReader, b *types.Block) { + c.headerExtra = slices.Clone(b.Header().Extra) } func (c *concat) Gas(tx *types.Transaction) (uint64, bool) { @@ -77,6 +77,8 @@ func (c *concat) Process(sdb libevm.StateReader, i int, tx *types.Transaction) [ ) } +func (*concat) AfterBlock(StateDB, *types.Block, types.Receipts) {} + func TestProcessor(t *testing.T) { handler := &concat{ addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, @@ -173,8 +175,8 @@ func TestProcessor(t *testing.T) { 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) + require.NoError(t, p.StartBlock(sdb, rules, block), "StartBlock()") + defer p.FinishBlock(sdb, block, nil) for i, tx := range txs { wantOK := wantProcessed[i] @@ -305,8 +307,7 @@ func TestIntegration(t *testing.T) { } block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil)) - require.NoError(t, sut.StartBlock(block, rules, state), "StartBlock()") - defer sut.FinishBlock(block) + require.NoError(t, sut.StartBlock(state, rules, block), "StartBlock()") pool := core.GasPool(math.MaxUint64) var got []*types.Receipt @@ -332,4 +333,5 @@ func TestIntegration(t *testing.T) { if diff := cmp.Diff(want, got, ignore); diff != "" { t.Errorf("%T diff (-want +got):\n%s", got, diff) } + sut.FinishBlock(state, block, got) } From ed05fb6210f97a7a5627ace089b4d827708e8fd5 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 30 Oct 2025 21:03:22 +0000 Subject: [PATCH 17/24] feat: `Handler.Prefetch()` pipelines into `Handler.Process()` --- libevm/precompiles/parallel/parallel.go | 94 ++++++++----- libevm/precompiles/parallel/parallel_test.go | 138 ++++++++++++------- 2 files changed, 149 insertions(+), 83 deletions(-) diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index cab218851695..f35d0b7208ab 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -48,10 +48,11 @@ import ( // All [libevm.StateReader] instances are opened to the state at the beginning // of the block. The [StateDB] is the same one used to execute the block, // before being committed, and MAY be written to. -type Handler[Result any] interface { +type Handler[Data, Result any] interface { BeforeBlock(libevm.StateReader, *types.Block) Gas(*types.Transaction) (gas uint64, process bool) - Process(sdb libevm.StateReader, index int, tx *types.Transaction) Result + Prefetch(sdb libevm.StateReader, index int, tx *types.Transaction) Data + Process(sdb libevm.StateReader, index int, tx *types.Transaction, data Data) Result AfterBlock(StateDB, *types.Block, types.Receipts) } @@ -63,13 +64,14 @@ type StateDB interface { } // 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 Processor[D, R any] struct { + handler Handler[D, R] + workers sync.WaitGroup + prefetch, process chan *job + data [](chan D) + results [](chan result[R]) + txGas map[common.Hash]uint64 + stateShare stateDBSharer } type job struct { @@ -85,13 +87,16 @@ type result[T any] struct { // 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), +func New[D, R any](h Handler[D, R], prefetchers, processors int) *Processor[D, R] { + prefetchers = max(prefetchers, 1) + processors = max(processors, 1) + workers := prefetchers + processors + + p := &Processor[D, R]{ + handler: h, + prefetch: make(chan *job), + process: make(chan *job), + txGas: make(map[common.Hash]uint64), stateShare: stateDBSharer{ workers: workers, nextAvailable: make(chan struct{}), @@ -100,8 +105,11 @@ func New[R any](h Handler[R], workers int) *Processor[R] { 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() + for range prefetchers { + go p.worker(p.prefetch, nil) + } + for range processors { + go p.worker(nil, p.process) } p.stateShare.wg.Wait() @@ -131,7 +139,7 @@ func (s *stateDBSharer) distribute(sdb *state.StateDB) { s.wg.Wait() } -func (p *Processor[R]) worker() { +func (p *Processor[D, R]) worker(prefetch, process chan *job) { defer p.workers.Done() var sdb *state.StateDB @@ -152,14 +160,20 @@ func (p *Processor[R]) worker() { stateAvailable = share.nextAvailable share.wg.Done() - case w, ok := <-p.work: + case job, ok := <-prefetch: + if !ok { + return + } + p.data[job.index] <- p.handler.Prefetch(sdb, job.index, job.tx) + + case job, ok := <-process: if !ok { return } - r := p.handler.Process(sdb, w.index, w.tx) - p.results[w.index] <- result[R]{ - tx: w.tx.Hash(), + r := p.handler.Process(sdb, job.index, job.tx, <-p.data[job.index]) + p.results[job.index] <- result[R]{ + tx: job.tx.Hash(), val: &r, } } @@ -167,15 +181,16 @@ func (p *Processor[R]) worker() { } // Close shuts down the [Processor], after which it can no longer be used. -func (p *Processor[R]) Close() { - close(p.work) +func (p *Processor[D, R]) Close() { + close(p.prefetch) + close(p.process) 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(sdb *state.StateDB, rules params.Rules, b *types.Block) error { +func (p *Processor[D, R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { // The distribution mechanism copies the StateDB so we don't need to do it // here, but the [Handler] is called directly so we do copy. p.stateShare.distribute(sdb) @@ -191,9 +206,10 @@ func (p *Processor[R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *typ 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]. + // We can reuse the channels already in the data and results slices because + // they're emptied by [Processor.FinishBlock]. for i, n := len(p.results), len(txs); i < n; i++ { + p.data = append(p.data, make(chan D, 1)) p.results = append(p.results, make(chan result[R], 1)) } @@ -216,11 +232,17 @@ func (p *Processor[R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *typ } } + // The first goroutine pipelines into the second, which has its results + // emptied by [Processor.FinishBlock]. The return of said function therefore + // guarantees that we haven't leaked either of these. + go func() { + for _, j := range jobs { + p.prefetch <- j + } + }() go func() { - // This goroutine is guaranteed to have returned by the time - // [Processor.FinishBlock] does. for _, j := range jobs { - p.work <- j + p.process <- j } }() return nil @@ -229,7 +251,7 @@ func (p *Processor[R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *typ // 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(sdb vm.StateDB, b *types.Block, rs types.Receipts) { +func (p *Processor[D, R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { 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 @@ -251,7 +273,7 @@ func (p *Processor[R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Rece // [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) { +func (p *Processor[D, R]) Result(i int) (R, bool) { ch := p.results[i] r := <-ch defer func() { @@ -267,7 +289,7 @@ func (p *Processor[R]) Result(i int) (R, bool) { return *r.val, true } -func (p *Processor[R]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, retErr error) { +func (p *Processor[R, D]) 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 @@ -317,7 +339,7 @@ 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) { +func (p *Processor[R, D]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { g, ok := p.txGas[tx] if !ok { return 0, fmt.Errorf("%w: %v", ErrTxUnknown, tx) @@ -325,4 +347,4 @@ func (p *Processor[R]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { return g, nil } -var _ vm.Preprocessor = (*Processor[struct{}])(nil) +var _ vm.Preprocessor = (*Processor[struct{}, struct{}])(nil) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 7ed2589c4f53..41a6d779f476 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -17,7 +17,6 @@ package parallel import ( - "bytes" "encoding/binary" "math" "math/big" @@ -47,45 +46,74 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, goleak.IgnoreCurrent()) } -type concat struct { - headerExtra []byte - addr common.Address - stateKey common.Hash - gas uint64 +type recorder struct { + gas uint64 + addr common.Address + blockKey, prefetchKey, processKey common.Hash + + gotHeaderExtra []byte + gotBlockVal common.Hash + gotReceipts types.Receipts } -func (c *concat) BeforeBlock(_ libevm.StateReader, b *types.Block) { - c.headerExtra = slices.Clone(b.Header().Extra) +func (r *recorder) BeforeBlock(sdb libevm.StateReader, b *types.Block) { + r.gotHeaderExtra = slices.Clone(b.Header().Extra) + r.gotBlockVal = sdb.GetState(r.addr, r.blockKey) } -func (c *concat) Gas(tx *types.Transaction) (uint64, bool) { - if to := tx.To(); to != nil && *to == c.addr { - return c.gas, true +func (r *recorder) Gas(tx *types.Transaction) (uint64, bool) { + if to := tx.To(); to != nil && *to == r.addr { + return r.gas, true } return 0, false } -func concatOutput(txData []byte, state common.Hash, extra []byte) []byte { - return slices.Concat(txData, state[:], extra) +func (r *recorder) Prefetch(sdb libevm.StateReader, i int, tx *types.Transaction) common.Hash { + return sdb.GetState(r.addr, r.prefetchKey) } -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, - ) +type recorded struct { + HeaderExtra, TxData []byte + Block, Prefetch, Process common.Hash +} + +func (r *recorder) Process(sdb libevm.StateReader, i int, tx *types.Transaction, prefetched common.Hash) recorded { + return recorded{ + HeaderExtra: slices.Clone(r.gotHeaderExtra), + TxData: slices.Clone(tx.Data()), + Block: r.gotBlockVal, + Prefetch: prefetched, + Process: sdb.GetState(r.addr, r.processKey), + } +} + +func (r *recorded) asLog() *types.Log { + return &types.Log{ + Topics: []common.Hash{ + r.Block, r.Prefetch, r.Process, + }, + Data: slices.Concat(r.HeaderExtra, []byte("|"), r.TxData), + } +} + +func (r *recorder) AfterBlock(_ StateDB, _ *types.Block, rs types.Receipts) { + r.gotReceipts = slices.Clone(rs) } -func (*concat) AfterBlock(StateDB, *types.Block, types.Receipts) {} +func asHash(s string) (h common.Hash) { + copy(h[:], []byte(s)) + return +} func TestProcessor(t *testing.T) { - handler := &concat{ - addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, - stateKey: common.Hash{'k', 'e', 'y'}, - gas: 1e6, + handler := &recorder{ + addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, + gas: 1e6, + blockKey: asHash("block"), + prefetchKey: asHash("prefetch"), + processKey: asHash("process"), } - p := New(handler, 8) + p := New(handler, 8, 8) t.Cleanup(p.Close) type blockParams struct { @@ -134,8 +162,13 @@ func TestProcessor(t *testing.T) { } _, _, sdb := ethtest.NewEmptyStateDB(t) - stateVal := common.Hash{'s', 't', 'a', 't', 'e'} - sdb.SetTransientState(handler.addr, handler.stateKey, stateVal) + h := handler + blockVal := asHash("block_val") + sdb.SetState(h.addr, h.blockKey, blockVal) + prefetchVal := asHash("prefetch_val") + sdb.SetState(h.addr, h.prefetchKey, prefetchVal) + processVal := asHash("process_val") + sdb.SetState(h.addr, h.processKey, processVal) for _, tt := range tests { t.Run("", func(t *testing.T) { @@ -181,14 +214,24 @@ func TestProcessor(t *testing.T) { for i, tx := range txs { wantOK := wantProcessed[i] - var want []byte + var want recorded if wantOK { - want = concatOutput(tx.Data(), stateVal, extra) + want = recorded{ + HeaderExtra: extra, + Block: blockVal, + Prefetch: prefetchVal, + Process: processVal, + TxData: tx.Data(), + } } 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 gotOK != wantOK { + t.Errorf("Result(%d) got ok %t; want %t", i, gotOK, wantOK) + continue + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Result(%d) diff (-want +got):\n%s", i, diff) } } }) @@ -210,11 +253,11 @@ func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) { func TestIntegration(t *testing.T) { const handlerGas = 500 - handler := &concat{ + handler := &recorder{ addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, gas: handlerGas, } - sut := New(handler, 8) + sut := New(handler, 8, 8) t.Cleanup(sut.Close) vm.RegisterHooks(&vmHooks{Preprocessor: sut}) @@ -232,9 +275,7 @@ func TestIntegration(t *testing.T) { if !ok { t.Errorf("no result for tx[%d] %v", txi, txh) } - sdb.AddLog(&types.Log{ - Data: got[:], - }) + sdb.AddLog(got.asLog()) return nil, nil }), }, @@ -251,7 +292,7 @@ func TestIntegration(t *testing.T) { var ( txs types.Transactions - want []*types.Receipt + want types.Receipts ) ignore := cmp.Options{ cmpopts.IgnoreFields( @@ -297,11 +338,14 @@ func TestIntegration(t *testing.T) { TransactionIndex: ui, } if addr == handler.addr { - wantR.Logs = []*types.Log{{ - TxHash: tx.Hash(), - TxIndex: ui, - Data: concatOutput(data, common.Hash{}, nil), - }} + want := (&recorded{ + TxData: tx.Data(), + }).asLog() + + want.TxHash = tx.Hash() + want.TxIndex = ui + + wantR.Logs = []*types.Log{want} } want = append(want, wantR) } @@ -310,7 +354,7 @@ func TestIntegration(t *testing.T) { require.NoError(t, sut.StartBlock(state, rules, block), "StartBlock()") pool := core.GasPool(math.MaxUint64) - var got []*types.Receipt + var receipts types.Receipts for i, tx := range txs { state.SetTxContext(tx.Hash(), i) @@ -327,11 +371,11 @@ func TestIntegration(t *testing.T) { vm.Config{}, ) require.NoError(t, err, "ApplyTransaction([%d])", i) - got = append(got, receipt) + receipts = append(receipts, receipt) } + sut.FinishBlock(state, block, receipts) - if diff := cmp.Diff(want, got, ignore); diff != "" { - t.Errorf("%T diff (-want +got):\n%s", got, diff) + if diff := cmp.Diff(want, handler.gotReceipts, ignore); diff != "" { + t.Errorf("%T diff (-want +got):\n%s", receipts, diff) } - sut.FinishBlock(state, block, got) } From e737446a3a0b5b0723cf9e4897ab1ad30fe91da8 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Fri, 31 Oct 2025 21:43:51 +0000 Subject: [PATCH 18/24] feat: `Handler.PostProcess()` method for result aggregation before end of block --- go.mod | 20 ++--- go.sum | 36 ++++----- libevm/libevm.go | 3 + libevm/precompiles/parallel/parallel.go | 77 +++++++++++++------- libevm/precompiles/parallel/parallel_test.go | 18 ++++- 5 files changed, 98 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index e7dc2f5b1c0b..0ae1145599bc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ava-labs/libevm -go 1.23 +go 1.24.8 require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 @@ -31,7 +31,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang/protobuf v1.5.3 github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb - github.com/google/go-cmp v0.5.9 + github.com/google/go-cmp v0.6.0 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.4.2 @@ -59,20 +59,20 @@ require ( github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible github.com/status-im/keycard-go v0.2.0 github.com/stretchr/testify v1.8.4 - github.com/supranational/blst v0.3.11 + github.com/supranational/blst v0.3.14 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 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/crypto v0.43.0 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa - golang.org/x/mod v0.14.0 - golang.org/x/sync v0.5.0 - golang.org/x/sys v0.16.0 - golang.org/x/text v0.14.0 + golang.org/x/mod v0.29.0 + golang.org/x/sync v0.17.0 + golang.org/x/sys v0.37.0 + golang.org/x/text v0.30.0 golang.org/x/time v0.3.0 - golang.org/x/tools v0.15.0 + golang.org/x/tools v0.38.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -139,7 +139,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/net v0.18.0 // indirect + golang.org/x/net v0.46.0 // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect rsc.io/tmplfunc v0.0.3 // indirect diff --git a/go.sum b/go.sum index 87821192c5ef..3f876903d968 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= @@ -580,8 +580,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/supranational/blst v0.3.11 h1:LyU6FolezeWAhvQk0k6O/d49jqgO52MSDDfYgbeoEm4= -github.com/supranational/blst v0.3.11/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo= +github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -635,8 +635,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -670,8 +670,8 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -711,8 +711,8 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -731,8 +731,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -796,8 +796,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -810,8 +810,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -866,8 +866,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= -golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/libevm/libevm.go b/libevm/libevm.go index a429d6048796..4f9e3a363b70 100644 --- a/libevm/libevm.go +++ b/libevm/libevm.go @@ -56,6 +56,9 @@ type StateReader interface { AddressInAccessList(addr common.Address) bool SlotInAccessList(addr common.Address, slot common.Hash) (addressOk bool, slotOk bool) + + TxHash() common.Hash + TxIndex() int } // AddressContext carries addresses available to contexts such as calls and diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index f35d0b7208ab..007093146b16 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -21,6 +21,7 @@ package parallel import ( "errors" "fmt" + "iter" "sync" "github.com/ava-labs/libevm/common" @@ -48,12 +49,13 @@ import ( // All [libevm.StateReader] instances are opened to the state at the beginning // of the block. The [StateDB] is the same one used to execute the block, // before being committed, and MAY be written to. -type Handler[Data, Result any] interface { +type Handler[Data, Result, Aggregated any] interface { BeforeBlock(libevm.StateReader, *types.Block) Gas(*types.Transaction) (gas uint64, process bool) Prefetch(sdb libevm.StateReader, index int, tx *types.Transaction) Data Process(sdb libevm.StateReader, index int, tx *types.Transaction, data Data) Result - AfterBlock(StateDB, *types.Block, types.Receipts) + PostProcess(iter.Seq2[int, Result]) Aggregated + AfterBlock(StateDB, Aggregated, *types.Block, types.Receipts) } // StateDB is the subset of [state.StateDB] methods that MAY be called by @@ -64,14 +66,17 @@ type StateDB interface { } // A Processor orchestrates dispatch and collection of results from a [Handler]. -type Processor[D, R any] struct { - handler Handler[D, R] - workers sync.WaitGroup +type Processor[D, R, A any] struct { + handler Handler[D, R, A] + workers sync.WaitGroup + + stateShare stateDBSharer + txGas map[common.Hash]uint64 + prefetch, process chan *job data [](chan D) results [](chan result[R]) - txGas map[common.Hash]uint64 - stateShare stateDBSharer + aggregated chan A } type job struct { @@ -87,20 +92,21 @@ type result[T any] struct { // 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[D, R any](h Handler[D, R], prefetchers, processors int) *Processor[D, R] { +func New[D, R, A any](h Handler[D, R, A], prefetchers, processors int) *Processor[D, R, A] { prefetchers = max(prefetchers, 1) processors = max(processors, 1) workers := prefetchers + processors - p := &Processor[D, R]{ - handler: h, - prefetch: make(chan *job), - process: make(chan *job), - txGas: make(map[common.Hash]uint64), + p := &Processor[D, R, A]{ + handler: h, stateShare: stateDBSharer{ workers: workers, nextAvailable: make(chan struct{}), }, + txGas: make(map[common.Hash]uint64), + prefetch: make(chan *job), + process: make(chan *job), + aggregated: make(chan A), } p.workers.Add(workers) // for shutdown via [Processor.Close] @@ -139,7 +145,7 @@ func (s *stateDBSharer) distribute(sdb *state.StateDB) { s.wg.Wait() } -func (p *Processor[D, R]) worker(prefetch, process chan *job) { +func (p *Processor[D, R, A]) worker(prefetch, process chan *job) { defer p.workers.Done() var sdb *state.StateDB @@ -181,7 +187,7 @@ func (p *Processor[D, R]) worker(prefetch, process chan *job) { } // Close shuts down the [Processor], after which it can no longer be used. -func (p *Processor[D, R]) Close() { +func (p *Processor[D, R, A]) Close() { close(p.prefetch) close(p.process) p.workers.Wait() @@ -190,7 +196,7 @@ func (p *Processor[D, R]) Close() { // 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[D, R]) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { +func (p *Processor[D, R, A]) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { // The distribution mechanism copies the StateDB so we don't need to do it // here, but the [Handler] is called directly so we do copy. p.stateShare.distribute(sdb) @@ -232,9 +238,6 @@ func (p *Processor[D, R]) StartBlock(sdb *state.StateDB, rules params.Rules, b * } } - // The first goroutine pipelines into the second, which has its results - // emptied by [Processor.FinishBlock]. The return of said function therefore - // guarantees that we haven't leaked either of these. go func() { for _, j := range jobs { p.prefetch <- j @@ -245,13 +248,33 @@ func (p *Processor[D, R]) StartBlock(sdb *state.StateDB, rules params.Rules, b * p.process <- j } }() + go func() { + n := len(b.Transactions()) + p.aggregated <- p.handler.PostProcess(p.resultIter(n)) + }() return nil } +func (p *Processor[D, R, A]) resultIter(n int) iter.Seq2[int, R] { + return func(yield func(int, R) bool) { + for i := range n { + r, ok := p.Result(i) + if !ok { + continue + } + if !yield(i, r) { + return + } + } + } +} + // 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[D, R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { +func (p *Processor[D, R, A]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { + p.handler.AfterBlock(sdb, <-p.aggregated, b, rs) + 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 @@ -259,7 +282,6 @@ func (p *Processor[D, R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.R tx := (<-p.results[i]).tx delete(p.txGas, tx) } - p.handler.AfterBlock(sdb, b, rs) } // Result blocks until the i'th transaction passed to [Processor.StartBlock] has @@ -271,9 +293,12 @@ func (p *Processor[D, R]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.R // 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[D, R]) Result(i int) (R, bool) { +// that if R is a pointer then modifications will persist between calls. The +// caller does NOT have mutually exclusive access to R, which MUST carry a mutex +// if thread safety is required. +func (p *Processor[D, R, A]) Result(i int) (R, bool) { ch := p.results[i] r := <-ch defer func() { @@ -289,7 +314,7 @@ func (p *Processor[D, R]) Result(i int) (R, bool) { return *r.val, true } -func (p *Processor[R, D]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, retErr error) { +func (p *Processor[R, D, S]) 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 @@ -339,7 +364,7 @@ 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, D]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { +func (p *Processor[R, D, S]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { g, ok := p.txGas[tx] if !ok { return 0, fmt.Errorf("%w: %v", ErrTxUnknown, tx) @@ -347,4 +372,4 @@ func (p *Processor[R, D]) PreprocessingGasCharge(tx common.Hash) (uint64, error) return g, nil } -var _ vm.Preprocessor = (*Processor[struct{}, struct{}])(nil) +var _ vm.Preprocessor = (*Processor[any, any, any])(nil) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 41a6d779f476..d949cbd68f8c 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -18,6 +18,8 @@ package parallel import ( "encoding/binary" + "iter" + "maps" "math" "math/big" "math/rand/v2" @@ -54,6 +56,7 @@ type recorder struct { gotHeaderExtra []byte gotBlockVal common.Hash gotReceipts types.Receipts + gotPerTx map[int]recorded } func (r *recorder) BeforeBlock(sdb libevm.StateReader, b *types.Block) { @@ -96,8 +99,13 @@ func (r *recorded) asLog() *types.Log { } } -func (r *recorder) AfterBlock(_ StateDB, _ *types.Block, rs types.Receipts) { +func (r *recorder) PostProcess(results iter.Seq2[int, recorded]) map[int]recorded { + return maps.Collect(results) +} + +func (r *recorder) AfterBlock(_ StateDB, perTx map[int]recorded, _ *types.Block, rs types.Receipts) { r.gotReceipts = slices.Clone(rs) + r.gotPerTx = perTx } func asHash(s string) (h common.Hash) { @@ -209,8 +217,8 @@ func TestProcessor(t *testing.T) { extra := []byte("extra") block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil)) require.NoError(t, p.StartBlock(sdb, rules, block), "StartBlock()") - defer p.FinishBlock(sdb, block, nil) + wantPerTx := make(map[int]recorded) for i, tx := range txs { wantOK := wantProcessed[i] @@ -223,6 +231,7 @@ func TestProcessor(t *testing.T) { Process: processVal, TxData: tx.Data(), } + wantPerTx[i] = want } got, gotOK := p.Result(i) @@ -234,6 +243,11 @@ func TestProcessor(t *testing.T) { t.Errorf("Result(%d) diff (-want +got):\n%s", i, diff) } } + + p.FinishBlock(sdb, block, nil) + if diff := cmp.Diff(wantPerTx, h.gotPerTx); diff != "" { + t.Errorf("handler.PostProcess() argument diff (-want +got):\n%s", diff) + } }) if t.Failed() { From f9afa5a5fe63867a207f2b4b4e09142c5a70215c Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Mon, 1 Dec 2025 11:31:11 +0000 Subject: [PATCH 19/24] feat: multi-`Handler` support + propagation of common data from `BeforeBlock()` --- libevm/precompiles/parallel/eventual.go | 53 ++++ libevm/precompiles/parallel/handler.go | 256 ++++++++++++++++ libevm/precompiles/parallel/parallel.go | 297 ++++++++----------- libevm/precompiles/parallel/parallel_test.go | 132 +++++++-- 4 files changed, 536 insertions(+), 202 deletions(-) create mode 100644 libevm/precompiles/parallel/eventual.go create mode 100644 libevm/precompiles/parallel/handler.go diff --git a/libevm/precompiles/parallel/eventual.go b/libevm/precompiles/parallel/eventual.go new file mode 100644 index 000000000000..dd1ec870d473 --- /dev/null +++ b/libevm/precompiles/parallel/eventual.go @@ -0,0 +1,53 @@ +// 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 + +// An eventual type holds a value that is set at some unknown point in the +// future and used, possibly concurrently, by one or more receivers. The zero +// value is NOT ready for use. +type eventual[T any] struct { + ch chan T +} + +// eventually returns a new eventual value. +func eventually[T any]() eventual[T] { + return eventual[T]{ + ch: make(chan T, 1), + } +} + +// set sets the value, unblocking any current and future getters. set itself is +// non-blocking, however it is NOT possible to overwrite the value without an +// intervening call to [eventual.getAndKeep]. +func (e eventual[T]) set(v T) { + e.ch <- v +} + +// getAndReplace returns the value after making it available for other getters. +// Although the act of getting and replacing is threadsafe, the returned value +// might not be. +func (e eventual[T]) getAndReplace() T { + v := <-e.ch + e.ch <- v + return v +} + +// getAndKeep returns the value and resets e to its default state as if +// immediately after construction. +func (e eventual[T]) getAndKeep() T { + return <-e.ch +} diff --git a/libevm/precompiles/parallel/handler.go b/libevm/precompiles/parallel/handler.go new file mode 100644 index 000000000000..79918c8aa2bf --- /dev/null +++ b/libevm/precompiles/parallel/handler.go @@ -0,0 +1,256 @@ +// 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 ( + "sync" + + "github.com/ava-labs/libevm/common" + "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/libevm/stateconf" +) + +// 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. +// +// All [libevm.StateReader] instances are opened to the state at the beginning +// of the block. The [StateDB] is the same one used to execute the block, +// before being committed, and MAY be written to. +// +// NOTE: other than [Handler.AfterBlock], all methods MAY be called concurrently +// with one another and with other [Handler] implementations, unless otherwise +// specified. AfterBlock() methods are called in the same order as they were +// registered with [AddHandler]. +type Handler[CommonData, Data, Result, Aggregated any] interface { + // Gas reports whether the [Handler] SHOULD receive the transaction for + // processing and, if so, how much gas to charge. Processing is performed + // i.f.f. the returned boolean is true and there is sufficient gas limit to + // cover intrinsic gas and all [Handler]s that returned true. If there is + // insufficient gas for processing then the transaction will result in + // [vm.ErrOutOfGas] as long as the [Processor] is registered with + // [vm.RegisterHooks] as a [vm.Preprocessor]. + Gas(*types.Transaction) (gas uint64, process bool) + // BeforeBlock is called before all calls to Prefetch() on this [Handler], + // all of which receive the returned value. + BeforeBlock(libevm.StateReader, *types.Block) CommonData + // Prefetch is called before the respective call to Process() on this + // [Handler]. It MUST NOT perform any meaningful computation beyond what is + // necessary to determine that necessary state to propagate to Process(). + Prefetch(libevm.StateReader, IndexedTx, CommonData) Data + // Process is responsible for performing all meaningful computation. It + // receives the common data returned by the single call to BeforeBlock() as + // well as the data from the respective call to Prefetch(). The returned + // result is propagated to PostProcess() and any calls to the function + // returned by [AddHandler]. + // + // NOTE: if the result is exposed to the EVM via a precompile then said + // precompile will block until Process() returns. While this guarantees the + // availability of pre-processed results, it is also the hot path for EVM + // transactions. + Process(libevm.StateReader, IndexedTx, CommonData, Data) Result + // PostProcess is called concurrently with all calls to Process(). It allows + // for online aggregation of results into a format ready for writing to + // state. + PostProcess(Results[Result]) Aggregated + // AfterBlock is called after PostProcess() returns and all regular EVM + // transaction processing is complete. + AfterBlock(StateDB, Aggregated, *types.Block, types.Receipts) +} + +// An IndexedTx couples a [types.Transaction] with its index in a block. +type IndexedTx struct { + Index int + *types.Transaction +} + +// Results provides mechanisms for blocking on the output of [Handler.Process]. +type Results[R any] struct { + WaitForAll func() + TxOrder, ProcessOrder <-chan TxResult[R] +} + +// A TxResult couples an [IndexedTx] with its respective result from +// [Handler.Process]. +type TxResult[R any] struct { + Tx IndexedTx + Result R +} + +// StateDB is the subset of [state.StateDB] methods that MAY be called by +// [Handler.AfterBlock]. +type StateDB interface { + libevm.StateReader + SetState(_ common.Address, key, val common.Hash, _ ...stateconf.StateDBStateOption) +} + +var _ handler = (*wrapper[any, any, any, any])(nil) + +// A wrapper exposes the generic functionality of a [Handler] in a non-generic +// manner, allowing [Processor] to be free of type parameters. +type wrapper[CD, D, R, A any] struct { + Handler[CD, D, R, A] + + totalTxsInBLock int + txsBeingProcessed sync.WaitGroup + + common eventual[CD] + data []eventual[D] + + results []eventual[result[R]] + whenProcessed chan TxResult[R] + + aggregated eventual[A] +} + +// AddHandler registers the [Handler] with the [Processor] and returns a +// function to fetch the [TxResult] for the i'th transaction passed to +// [Processor.StartBlock]. +// +// The returned function until the respective transaction 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]. +// +// Within the scope of a given block, 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. However, the caller does NOT have mutually +// exclusive access to the [TxResult] so SHOULD NOT modify it, especially since +// the result MAY also be accessed by [Handler.PostProcess], with no ordering +// guarantees. +func AddHandler[CD, D, R, A any](p *Processor, h Handler[CD, D, R, A]) func(txIndex int) (TxResult[R], bool) { + w := &wrapper[CD, D, R, A]{ + Handler: h, + common: eventually[CD](), + aggregated: eventually[A](), + } + p.handlers = append(p.handlers, w) + return w.result +} + +func (w *wrapper[CD, D, R, A]) beforeBlock(sdb libevm.StateReader, b *types.Block) { + w.totalTxsInBLock = len(b.Transactions()) + // We can reuse the channels already in the data and results slices because + // they're emptied by [wrapper.process] and [wrapper.finishBlock] + // respectively. + for i := len(w.results); i < w.totalTxsInBLock; i++ { + w.data = append(w.data, eventually[D]()) + w.results = append(w.results, eventually[result[R]]()) + } + + go func() { + // goroutine guaranteed to have completed by the time a respective + // getter unblocks (i.e. in any call to [wrapper.prefetch]). + w.common.set(w.BeforeBlock(sdb, b)) + }() +} + +func (w *wrapper[SD, D, R, A]) beforeWork(jobs int) { + w.txsBeingProcessed.Add(jobs) + w.whenProcessed = make(chan TxResult[R], jobs) + go func() { + w.txsBeingProcessed.Wait() + close(w.whenProcessed) + }() +} + +func (w *wrapper[SD, D, R, A]) prefetch(sdb libevm.StateReader, job *job) { + w.data[job.tx.Index].set(w.Prefetch(sdb, job.tx, w.common.getAndReplace())) +} + +func (w *wrapper[SD, D, R, A]) process(sdb libevm.StateReader, job *job) { + defer w.txsBeingProcessed.Done() + + idx := job.tx.Index + val := w.Process(sdb, job.tx, w.common.getAndReplace(), w.data[idx].getAndKeep()) + r := result[R]{ + tx: job.tx, + val: &val, + } + w.results[idx].set(r) + w.whenProcessed <- TxResult[R]{ + Tx: job.tx, + Result: val, + } +} + +func (w *wrapper[SD, D, R, A]) nullResult(job *job) { + w.results[job.tx.Index].set(result[R]{ + tx: job.tx, + val: nil, + }) +} + +func (w *wrapper[SD, D, R, A]) result(i int) (TxResult[R], bool) { + r := w.results[i].getAndReplace() + + txr := TxResult[R]{ + Tx: r.tx, + } + 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. + return txr, false + } + txr.Result = *r.val + return txr, true +} + +func (w *wrapper[SD, D, R, A]) postProcess() { + txOrder := make(chan TxResult[R], w.totalTxsInBLock) + go func() { + defer close(txOrder) + for i := range w.totalTxsInBLock { + r, ok := w.result(i) + if !ok { + continue + } + txOrder <- r + } + }() + + w.aggregated.set(w.PostProcess(Results[R]{ + WaitForAll: w.txsBeingProcessed.Wait, + TxOrder: txOrder, + ProcessOrder: w.whenProcessed, + })) +} + +func (p *wrapper[SD, D, R, A]) finishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { + p.AfterBlock(sdb, p.aggregated.getAndKeep(), b, rs) + p.common.getAndKeep() + for _, v := range p.results[:p.totalTxsInBLock] { + // 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. + v.getAndKeep() + } +} diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index 007093146b16..ea77f7932e03 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -14,14 +14,13 @@ // 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 provides functionality for precompiled contracts with +// lifespans of an entire block. package parallel import ( "errors" "fmt" - "iter" "sync" "github.com/ava-labs/libevm/common" @@ -30,83 +29,67 @@ import ( "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/libevm/stateconf" "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. -// -// All [libevm.StateReader] instances are opened to the state at the beginning -// of the block. The [StateDB] is the same one used to execute the block, -// before being committed, and MAY be written to. -type Handler[Data, Result, Aggregated any] interface { - BeforeBlock(libevm.StateReader, *types.Block) +// A handler is the non-generic equivalent of a [Handler], exposed by [wrapper]. +type handler interface { Gas(*types.Transaction) (gas uint64, process bool) - Prefetch(sdb libevm.StateReader, index int, tx *types.Transaction) Data - Process(sdb libevm.StateReader, index int, tx *types.Transaction, data Data) Result - PostProcess(iter.Seq2[int, Result]) Aggregated - AfterBlock(StateDB, Aggregated, *types.Block, types.Receipts) -} -// StateDB is the subset of [state.StateDB] methods that MAY be called by -// [Handler.AfterBlock]. -type StateDB interface { - libevm.StateReader - SetState(_ common.Address, key, val common.Hash, _ ...stateconf.StateDBStateOption) + beforeBlock(libevm.StateReader, *types.Block) + beforeWork(jobs int) + prefetch(libevm.StateReader, *job) + nullResult(*job) + process(libevm.StateReader, *job) + postProcess() + finishBlock(vm.StateDB, *types.Block, types.Receipts) } -// A Processor orchestrates dispatch and collection of results from a [Handler]. -type Processor[D, R, A any] struct { - handler Handler[D, R, A] - workers sync.WaitGroup - - stateShare stateDBSharer - txGas map[common.Hash]uint64 +// A Processor orchestrates dispatch and collection of results from one or more +// [Handler] instances. +type Processor struct { + handlers []handler + workers sync.WaitGroup + stateShare stateDBSharer prefetch, process chan *job - data [](chan D) - results [](chan result[R]) - aggregated chan A + + txGas map[common.Hash]uint64 } type job struct { - index int - tx *types.Transaction + handler handler + tx IndexedTx } type result[T any] struct { - tx common.Hash + tx IndexedTx val *T } // New constructs a new [Processor] with the specified number of concurrent -// workers. [Processor.Close] must be called after the final call to +// prefetching and processing workers. As prefetching is typically IO-bound, it +// is reasonable to have more prefetchers than processors. The number of +// processors SHOULD be determined from GOMAXPROCS. Pipelining in such a fashion +// stops prefetching for later transactions being blocked by earlier, +// long-running processing; see the respective methods on [Handler] for more +// context. +// +// [Processor.Close] MUST be called after the final call to // [Processor.FinishBlock] to avoid leaking goroutines. -func New[D, R, A any](h Handler[D, R, A], prefetchers, processors int) *Processor[D, R, A] { +func New(prefetchers, processors int) *Processor { prefetchers = max(prefetchers, 1) processors = max(processors, 1) workers := prefetchers + processors - p := &Processor[D, R, A]{ - handler: h, + p := &Processor{ stateShare: stateDBSharer{ workers: workers, nextAvailable: make(chan struct{}), }, - txGas: make(map[common.Hash]uint64), - prefetch: make(chan *job), - process: make(chan *job), - aggregated: make(chan A), + prefetch: make(chan *job), + process: make(chan *job), + txGas: make(map[common.Hash]uint64), } p.workers.Add(workers) // for shutdown via [Processor.Close] @@ -128,10 +111,12 @@ func New[D, R, A any](h Handler[D, R, A], prefetchers, processors int) *Processo // 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 + + mu sync.Mutex + primary *state.StateDB + + workers int + wg sync.WaitGroup } func (s *stateDBSharer) distribute(sdb *state.StateDB) { @@ -145,7 +130,7 @@ func (s *stateDBSharer) distribute(sdb *state.StateDB) { s.wg.Wait() } -func (p *Processor[D, R, A]) worker(prefetch, process chan *job) { +func (p *Processor) worker(prefetch, process chan *job) { defer p.workers.Done() var sdb *state.StateDB @@ -159,6 +144,8 @@ func (p *Processor[D, R, A]) worker(prefetch, process chan *job) { for { select { case <-stateAvailable: // guaranteed at the beginning of each block + // [state.StateDB.Copy] is a complex method that isn't explicitly + // documented as being threadsafe. share.mu.Lock() sdb = share.primary.Copy() share.mu.Unlock() @@ -170,74 +157,76 @@ func (p *Processor[D, R, A]) worker(prefetch, process chan *job) { if !ok { return } - p.data[job.index] <- p.handler.Prefetch(sdb, job.index, job.tx) + job.handler.prefetch(sdb, job) case job, ok := <-process: if !ok { return } - - r := p.handler.Process(sdb, job.index, job.tx, <-p.data[job.index]) - p.results[job.index] <- result[R]{ - tx: job.tx.Hash(), - val: &r, - } + job.handler.process(sdb, job) } } } // Close shuts down the [Processor], after which it can no longer be used. -func (p *Processor[D, R, A]) Close() { +func (p *Processor) Close() { close(p.prefetch) close(p.process) 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[D, R, A]) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { +// StartBlock dispatches transactions to every [Handler] but returns immediately +// after performing preliminary setup. It MUST be paired with a call to +// [Processor.FinishBlock], without overlap of blocks. +func (p *Processor) StartBlock(sdb *state.StateDB, rules params.Rules, b *types.Block) error { // The distribution mechanism copies the StateDB so we don't need to do it - // here, but the [Handler] is called directly so we do copy. + // here, but [wrapper.beforeBlock] doesn't make its own copy. Note that even + // reading from a [state.StateDB] is not threadsafe. p.stateShare.distribute(sdb) - p.handler.BeforeBlock( - sdb.Copy(), - types.NewBlockWithHeader( - b.Header(), - ).WithBody( - *b.Body(), - ), - ) + for _, h := range p.handlers { + h.beforeBlock( + sdb.Copy(), + types.NewBlockWithHeader( + b.Header(), + ).WithBody( + *b.Body(), + ), + ) + } txs := b.Transactions() - jobs := make([]*job, 0, len(txs)) - - // We can reuse the channels already in the data and results slices because - // they're emptied by [Processor.FinishBlock]. - for i, n := len(p.results), len(txs); i < n; i++ { - p.data = append(p.data, make(chan D, 1)) - p.results = append(p.results, make(chan result[R], 1)) - } + jobs := make([]*job, 0, len(p.handlers)*len(txs)) + workloads := make([]int, len(p.handlers)) - for i, tx := range txs { - switch do, err := p.shouldProcess(tx, rules); { - case err != nil: + for txIdx, tx := range txs { + do, err := p.shouldProcess(tx, rules) + if 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, + } + for i, h := range p.handlers { + j := &job{ + tx: IndexedTx{ + Index: txIdx, + Transaction: tx, + }, + handler: h, + } + if !do[i] { + h.nullResult(j) + continue } + workloads[i]++ + jobs = append(jobs, j) } } + for i, w := range workloads { + p.handlers[i].beforeWork(w) + } + // All of the following goroutines are dependent on the one(s) preceding + // them, while [wrapper.finishBlock] is dependent on [wrapper.postProcess]. + // The return of [Processor.FinishBlock] is therefore a guarantee of the end + // of the lifespans of all of these goroutines. go func() { for _, j := range jobs { p.prefetch <- j @@ -248,97 +237,65 @@ func (p *Processor[D, R, A]) StartBlock(sdb *state.StateDB, rules params.Rules, p.process <- j } }() - go func() { - n := len(b.Transactions()) - p.aggregated <- p.handler.PostProcess(p.resultIter(n)) - }() + for _, h := range p.handlers { + go h.postProcess() + } return nil } -func (p *Processor[D, R, A]) resultIter(n int) iter.Seq2[int, R] { - return func(yield func(int, R) bool) { - for i := range n { - r, ok := p.Result(i) - if !ok { - continue - } - if !yield(i, r) { - return - } - } +// FinishBlock propagates its arguments to every [Handler] and resets 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) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { + // [Handler.FinishBlock] is allowed to write to state, so these MUST NOT be + // concurrent. + for _, h := range p.handlers { + h.finishBlock(sdb, b, rs) } -} - -// 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[D, R, A]) FinishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { - p.handler.AfterBlock(sdb, <-p.aggregated, b, rs) - - 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 + for tx := range p.txGas { 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. The -// caller does NOT have mutually exclusive access to R, which MUST carry a mutex -// if thread safety is required. -func (p *Processor[D, R, A]) 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, D, S]) shouldProcess(tx *types.Transaction, rules params.Rules) (process bool, retErr error) { +func (p *Processor) 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 + process = make([]bool, len(p.handlers)) + var totalCost uint64 + for i, h := range p.handlers { + cost, ok := h.Gas(tx) + if !ok { + continue + } + process[i] = true + totalCost += cost } + defer func() { - if process && retErr == nil { - p.txGas[tx.Hash()] = cost + if retErr == nil { + p.txGas[tx.Hash()] = totalCost } }() spent, err := txIntrinsicGas(tx, &rules) if err != nil { - return false, fmt.Errorf("calculating intrinsic gas of %v: %v", tx.Hash(), err) + return nil, fmt.Errorf("calculating intrinsic gas of %#x: %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 + // have been included. We include the check, however, for completeness + // as we would otherwise underflow below. + return nil, core.ErrIntrinsicGas + } + if remain := tx.Gas() - spent; remain < totalCost { + for i := range process { + process[i] = false + } } - return tx.Gas()-spent >= cost, nil + return process, nil } func txIntrinsicGas(tx *types.Transaction, rules *params.Rules) (uint64, error) { @@ -364,7 +321,7 @@ 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, D, S]) PreprocessingGasCharge(tx common.Hash) (uint64, error) { +func (p *Processor) PreprocessingGasCharge(tx common.Hash) (uint64, error) { g, ok := p.txGas[tx] if !ok { return 0, fmt.Errorf("%w: %v", ErrTxUnknown, tx) @@ -372,4 +329,4 @@ func (p *Processor[R, D, S]) PreprocessingGasCharge(tx common.Hash) (uint64, err return g, nil } -var _ vm.Preprocessor = (*Processor[any, any, any])(nil) +var _ vm.Preprocessor = (*Processor)(nil) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index d949cbd68f8c..27dd532cea84 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -18,8 +18,6 @@ package parallel import ( "encoding/binary" - "iter" - "maps" "math" "math/big" "math/rand/v2" @@ -49,19 +47,35 @@ func TestMain(m *testing.M) { } type recorder struct { + tb testing.TB + gas uint64 addr common.Address blockKey, prefetchKey, processKey common.Hash - gotHeaderExtra []byte - gotBlockVal common.Hash - gotReceipts types.Receipts - gotPerTx map[int]recorded + gotReceipts types.Receipts + gotAggregated aggregated } -func (r *recorder) BeforeBlock(sdb libevm.StateReader, b *types.Block) { - r.gotHeaderExtra = slices.Clone(b.Header().Extra) - r.gotBlockVal = sdb.GetState(r.addr, r.blockKey) +type aggregated struct { + txOrder, processOrder []TxResult[recorded] +} + +type recorded struct { + HeaderExtra, TxData []byte + Block, Prefetch, Process common.Hash +} + +type commonData struct { + HeaderExtra []byte + BeforeBlockStateVal common.Hash +} + +func (r *recorder) BeforeBlock(sdb libevm.StateReader, b *types.Block) commonData { + return commonData{ + HeaderExtra: slices.Clone(b.Header().Extra), + BeforeBlockStateVal: sdb.GetState(r.addr, r.blockKey), + } } func (r *recorder) Gas(tx *types.Transaction) (uint64, bool) { @@ -71,21 +85,28 @@ func (r *recorder) Gas(tx *types.Transaction) (uint64, bool) { return 0, false } -func (r *recorder) Prefetch(sdb libevm.StateReader, i int, tx *types.Transaction) common.Hash { - return sdb.GetState(r.addr, r.prefetchKey) +type prefetched struct { + prefetchStateVal common.Hash + common commonData } -type recorded struct { - HeaderExtra, TxData []byte - Block, Prefetch, Process common.Hash +func (r *recorder) Prefetch(sdb libevm.StateReader, tx IndexedTx, cd commonData) prefetched { + return prefetched{ + common: cd, + prefetchStateVal: sdb.GetState(r.addr, r.prefetchKey), + } } -func (r *recorder) Process(sdb libevm.StateReader, i int, tx *types.Transaction, prefetched common.Hash) recorded { +func (r *recorder) Process(sdb libevm.StateReader, tx IndexedTx, cd commonData, data prefetched) recorded { + if diff := cmp.Diff(cd, data.common); diff != "" { + r.tb.Errorf("Mismatched CommonData propagation to Handler methods; diff (-Process, +Prefetch):\n%s", diff) + } + return recorded{ - HeaderExtra: slices.Clone(r.gotHeaderExtra), + HeaderExtra: slices.Clone(cd.HeaderExtra), TxData: slices.Clone(tx.Data()), - Block: r.gotBlockVal, - Prefetch: prefetched, + Block: cd.BeforeBlockStateVal, + Prefetch: data.prefetchStateVal, Process: sdb.GetState(r.addr, r.processKey), } } @@ -99,13 +120,23 @@ func (r *recorded) asLog() *types.Log { } } -func (r *recorder) PostProcess(results iter.Seq2[int, recorded]) map[int]recorded { - return maps.Collect(results) +func (r *recorder) PostProcess(res Results[recorded]) aggregated { + defer res.WaitForAll() + + var out aggregated + for res := range res.TxOrder { + out.txOrder = append(out.txOrder, res) + } + for res := range res.ProcessOrder { + out.processOrder = append(out.processOrder, res) + } + + return out } -func (r *recorder) AfterBlock(_ StateDB, perTx map[int]recorded, _ *types.Block, rs types.Receipts) { +func (r *recorder) AfterBlock(_ StateDB, agg aggregated, _ *types.Block, rs types.Receipts) { r.gotReceipts = slices.Clone(rs) - r.gotPerTx = perTx + r.gotAggregated = agg } func asHash(s string) (h common.Hash) { @@ -115,13 +146,15 @@ func asHash(s string) (h common.Hash) { func TestProcessor(t *testing.T) { handler := &recorder{ + tb: t, addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, gas: 1e6, blockKey: asHash("block"), prefetchKey: asHash("prefetch"), processKey: asHash("process"), } - p := New(handler, 8, 8) + p := New(8, 8) + getResult := AddHandler(p, handler) t.Cleanup(p.Close) type blockParams struct { @@ -218,7 +251,7 @@ func TestProcessor(t *testing.T) { block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil)) require.NoError(t, p.StartBlock(sdb, rules, block), "StartBlock()") - wantPerTx := make(map[int]recorded) + var wantPerTx []TxResult[recorded] for i, tx := range txs { wantOK := wantProcessed[i] @@ -231,22 +264,55 @@ func TestProcessor(t *testing.T) { Process: processVal, TxData: tx.Data(), } - wantPerTx[i] = want + wantPerTx = append(wantPerTx, TxResult[recorded]{ + Tx: IndexedTx{ + Index: i, + Transaction: tx, + }, + Result: want, + }) } - got, gotOK := p.Result(i) + got, gotOK := getResult(i) if gotOK != wantOK { t.Errorf("Result(%d) got ok %t; want %t", i, gotOK, wantOK) continue } - if diff := cmp.Diff(want, got); diff != "" { + if diff := cmp.Diff(want, got.Result); diff != "" { t.Errorf("Result(%d) diff (-want +got):\n%s", i, diff) } } p.FinishBlock(sdb, block, nil) - if diff := cmp.Diff(wantPerTx, h.gotPerTx); diff != "" { - t.Errorf("handler.PostProcess() argument diff (-want +got):\n%s", diff) + tests := []struct { + name string + got []TxResult[recorded] + opt cmp.Option + }{ + { + name: "in_transaction_order", + got: h.gotAggregated.txOrder, + }, + { + name: "in_process_order", + got: h.gotAggregated.processOrder, + opt: cmpopts.SortSlices(func(a, b TxResult[recorded]) bool { + return a.Tx.Index < b.Tx.Index + }), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := cmp.Options{ + tt.opt, + cmp.Comparer(func(a, b *types.Transaction) bool { + return a.Hash() == b.Hash() + }), + } + if diff := cmp.Diff(wantPerTx, tt.got, opts); diff != "" { + t.Errorf("handler.PostProcess() argument diff (-want +got):\n%s", diff) + } + }) } }) @@ -268,10 +334,12 @@ func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) { func TestIntegration(t *testing.T) { const handlerGas = 500 handler := &recorder{ + tb: t, addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'}, gas: handlerGas, } - sut := New(handler, 8, 8) + sut := New(8, 8) + getResult := AddHandler(sut, handler) t.Cleanup(sut.Close) vm.RegisterHooks(&vmHooks{Preprocessor: sut}) @@ -285,11 +353,11 @@ func TestIntegration(t *testing.T) { // Precompiles MUST NOT charge gas for the preprocessing as it // would then be double-counted. - got, ok := sut.Result(txi) + got, ok := getResult(txi) if !ok { t.Errorf("no result for tx[%d] %v", txi, txh) } - sdb.AddLog(got.asLog()) + sdb.AddLog(got.Result.asLog()) return nil, nil }), }, From e66b7c60956c223fd16bb01a94e05440e95df788 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 2 Dec 2025 14:22:57 +0000 Subject: [PATCH 20/24] feat: `AddAsPrecompile()` --- libevm/precompiles/parallel/parallel_test.go | 81 +++++++++++++------- libevm/precompiles/parallel/precompile.go | 72 +++++++++++++++++ 2 files changed, 126 insertions(+), 27 deletions(-) create mode 100644 libevm/precompiles/parallel/precompile.go diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 27dd532cea84..a5853fafbe1e 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -111,16 +111,25 @@ func (r *recorder) Process(sdb libevm.StateReader, tx IndexedTx, cd commonData, } } -func (r *recorded) asLog() *types.Log { +var _ PrecompileResult = recorded{} + +func (r recorded) PrecompileOutput(env vm.PrecompileEnvironment, input []byte) ([]byte, []*types.Log, error) { + return r.precompileReturnData(), []*types.Log{r.asLog()}, nil +} + +func (r recorded) precompileReturnData() []byte { + return slices.Concat(r.HeaderExtra, []byte("|"), r.TxData) +} + +func (r recorded) asLog() *types.Log { return &types.Log{ - Topics: []common.Hash{ - r.Block, r.Prefetch, r.Process, - }, - Data: slices.Concat(r.HeaderExtra, []byte("|"), r.TxData), + Topics: []common.Hash{r.Block, r.Prefetch, r.Process}, } } func (r *recorder) PostProcess(res Results[recorded]) aggregated { + // Although unnecessary because of the ranging over both channels, this just + // demonstrates that it's non-blocking. defer res.WaitForAll() var out aggregated @@ -339,7 +348,7 @@ func TestIntegration(t *testing.T) { gas: handlerGas, } sut := New(8, 8) - getResult := AddHandler(sut, handler) + precompile := AddAsPrecompile(sut, handler) t.Cleanup(sut.Close) vm.RegisterHooks(&vmHooks{Preprocessor: sut}) @@ -347,19 +356,7 @@ func TestIntegration(t *testing.T) { 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 := getResult(txi) - if !ok { - t.Errorf("no result for tx[%d] %v", txi, txh) - } - sdb.AddLog(got.Result.asLog()) - return nil, nil - }), + handler.addr: vm.NewStatefulPrecompile(precompile), }, } stub.Register(t) @@ -373,8 +370,9 @@ func TestIntegration(t *testing.T) { state.SetBalance(eoa, new(uint256.Int).SetAllOne()) var ( - txs types.Transactions - want types.Receipts + txs types.Transactions + wantReturnData [][]byte + wantReceipts types.Receipts ) ignore := cmp.Options{ cmpopts.IgnoreFields( @@ -419,17 +417,24 @@ func TestIntegration(t *testing.T) { GasUsed: gas, TransactionIndex: ui, } - if addr == handler.addr { - want := (&recorded{ - TxData: tx.Data(), - }).asLog() + if addr != handler.addr { + wantReturnData = append(wantReturnData, []byte{}) + } else { + rec := &recorded{ + HeaderExtra: slices.Clone(header.Extra), + TxData: tx.Data(), + } + wantReturnData = append(wantReturnData, rec.precompileReturnData()) + want := rec.asLog() + + want.Address = handler.addr want.TxHash = tx.Hash() want.TxIndex = ui wantR.Logs = []*types.Log{want} } - want = append(want, wantR) + wantReceipts = append(wantReceipts, wantR) } block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil)) @@ -440,6 +445,26 @@ func TestIntegration(t *testing.T) { for i, tx := range txs { state.SetTxContext(tx.Hash(), i) + t.Run("precompile_return_data", func(t *testing.T) { + // Although [core.ApplyTransaction] is used to get receipts, it + // doesn't provide access to return data. We therefore *also* use + // [core.ApplyMessage] but MUST avoid repeating the same state + // transition as it would fail the second time. + id := evm.StateDB.Snapshot() + t.Cleanup(func() { + evm.StateDB.RevertToSnapshot(id) + }) + + msg, err := core.TransactionToMessage(tx, signer, big.NewInt(0)) + require.NoError(t, err, "core.TransactionToMessage()") + + got, err := core.ApplyMessage(evm, msg, &pool) + require.NoError(t, err, "core.ApplyMessage()") + if diff := cmp.Diff(wantReturnData[i], got.ReturnData, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Return data from precompile (-want +got):\n%s", diff) + } + }) + var usedGas uint64 receipt, err := core.ApplyTransaction( evm.ChainConfig(), @@ -457,7 +482,9 @@ func TestIntegration(t *testing.T) { } sut.FinishBlock(state, block, receipts) - if diff := cmp.Diff(want, handler.gotReceipts, ignore); diff != "" { + if diff := cmp.Diff(wantReceipts, handler.gotReceipts, ignore); diff != "" { t.Errorf("%T diff (-want +got):\n%s", receipts, diff) } } + +// TODO(arr4n) unit test for [AddPrecompile] unhappy paths. diff --git a/libevm/precompiles/parallel/precompile.go b/libevm/precompiles/parallel/precompile.go new file mode 100644 index 000000000000..eb49ea4f2676 --- /dev/null +++ b/libevm/precompiles/parallel/precompile.go @@ -0,0 +1,72 @@ +// 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 ( + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/core/vm" +) + +// PrecompileResult is the interface required for a [Handler] to be converted +// into a [vm.PrecompiledStatefulContract]. +type PrecompileResult interface { + // PrecompileOutput's arguments match those of + // [vm.PrecompiledStatefulContract], except for the addition of logs to be + // recorded in the event of non-reverting output. Although the + // implementation MAY manage logging, it SHOULD prefer the return argument + // as this ensures proper R/W and address management. + // + // PrecompileOutput MUST NOT re-charge the `Gas()` amount returned by the + // [Handler], but MAY charge for other computation if necessary. + PrecompileOutput(vm.PrecompileEnvironment, []byte) ([]byte, []*types.Log, error) +} + +// AddAsPrecompile is equivalent to [AddHandler] except that the returned +// function is a [vm.PrecompiledStatefulContract] instead of a raw result +// fetcher. If the function returned by [AddHandler] returns `false` then the +// precompile returns [vm.ErrExecutionReverted]. All logs returned by the +// [PrecompileResult] have their address field populated automatically before +// being logged. +func AddAsPrecompile[CD, D any, R PrecompileResult, A any](p *Processor, h Handler[CD, D, R, A]) vm.PrecompiledStatefulContract { + results := AddHandler(p, h) + + return func(env vm.PrecompileEnvironment, input []byte) ([]byte, error) { + res, ok := results(env.ReadOnlyState().TxIndex()) + if !ok { + // TODO(arr4n) add revert data to match a Solidity-style error + return nil, vm.ErrExecutionReverted + } + + ret, logs, err := res.Result.PrecompileOutput(env, input) + if err != nil { + // This MUST NOT be `nil, err` as the EVM uses the returned buffer + // for both successful and reverting paths. + return ret, err + } + + if env.ReadOnly() && len(logs) > 0 { + return nil, vm.ErrWriteProtection + } + sdb := env.StateDB() + self := env.Addresses().EVMSemantic.Self + for _, l := range logs { + l.Address = self + sdb.AddLog(l) + } + return ret, nil + } +} From 45ca37aac3b35c66a5ab812d4b7601d8841b7335 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Tue, 2 Dec 2025 20:21:53 +0000 Subject: [PATCH 21/24] refactor!: remove logs returned by precompile; use `PrecompileEnvironment` instead --- libevm/precompiles/parallel/parallel_test.go | 7 +++- libevm/precompiles/parallel/precompile.go | 40 ++++---------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index a5853fafbe1e..6a88f9870d48 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -113,8 +113,11 @@ func (r *recorder) Process(sdb libevm.StateReader, tx IndexedTx, cd commonData, var _ PrecompileResult = recorded{} -func (r recorded) PrecompileOutput(env vm.PrecompileEnvironment, input []byte) ([]byte, []*types.Log, error) { - return r.precompileReturnData(), []*types.Log{r.asLog()}, nil +func (r recorded) PrecompileOutput(env vm.PrecompileEnvironment, input []byte) ([]byte, error) { + l := r.asLog() + l.Address = env.Addresses().EVMSemantic.Self + env.StateDB().AddLog(l) + return r.precompileReturnData(), nil } func (r recorded) precompileReturnData() []byte { diff --git a/libevm/precompiles/parallel/precompile.go b/libevm/precompiles/parallel/precompile.go index eb49ea4f2676..b16e77a667e4 100644 --- a/libevm/precompiles/parallel/precompile.go +++ b/libevm/precompiles/parallel/precompile.go @@ -16,31 +16,22 @@ package parallel -import ( - "github.com/ava-labs/libevm/core/types" - "github.com/ava-labs/libevm/core/vm" -) +import "github.com/ava-labs/libevm/core/vm" // PrecompileResult is the interface required for a [Handler] to be converted // into a [vm.PrecompiledStatefulContract]. type PrecompileResult interface { // PrecompileOutput's arguments match those of - // [vm.PrecompiledStatefulContract], except for the addition of logs to be - // recorded in the event of non-reverting output. Although the - // implementation MAY manage logging, it SHOULD prefer the return argument - // as this ensures proper R/W and address management. - // - // PrecompileOutput MUST NOT re-charge the `Gas()` amount returned by the - // [Handler], but MAY charge for other computation if necessary. - PrecompileOutput(vm.PrecompileEnvironment, []byte) ([]byte, []*types.Log, error) + // [vm.PrecompiledStatefulContract]. It MUST NOT re-charge the `Gas()` + // amount returned by the [Handler], but MAY charge for other computation as + // necessary. + PrecompileOutput(vm.PrecompileEnvironment, []byte) ([]byte, error) } // AddAsPrecompile is equivalent to [AddHandler] except that the returned // function is a [vm.PrecompiledStatefulContract] instead of a raw result // fetcher. If the function returned by [AddHandler] returns `false` then the -// precompile returns [vm.ErrExecutionReverted]. All logs returned by the -// [PrecompileResult] have their address field populated automatically before -// being logged. +// precompile returns [vm.ErrExecutionReverted]. func AddAsPrecompile[CD, D any, R PrecompileResult, A any](p *Processor, h Handler[CD, D, R, A]) vm.PrecompiledStatefulContract { results := AddHandler(p, h) @@ -50,23 +41,6 @@ func AddAsPrecompile[CD, D any, R PrecompileResult, A any](p *Processor, h Handl // TODO(arr4n) add revert data to match a Solidity-style error return nil, vm.ErrExecutionReverted } - - ret, logs, err := res.Result.PrecompileOutput(env, input) - if err != nil { - // This MUST NOT be `nil, err` as the EVM uses the returned buffer - // for both successful and reverting paths. - return ret, err - } - - if env.ReadOnly() && len(logs) > 0 { - return nil, vm.ErrWriteProtection - } - sdb := env.StateDB() - self := env.Addresses().EVMSemantic.Self - for _, l := range logs { - l.Address = self - sdb.AddLog(l) - } - return ret, nil + return res.Result.PrecompileOutput(env, input) } } From 2954fb52dc31c861b14fbd0b2272493ddd9d6a6b Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 3 Dec 2025 15:21:43 +0000 Subject: [PATCH 22/24] doc: `Handler` --- libevm/precompiles/parallel/handler.go | 60 +++++++++++++++++--------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/libevm/precompiles/parallel/handler.go b/libevm/precompiles/parallel/handler.go index 79918c8aa2bf..18f7af6e1ec4 100644 --- a/libevm/precompiles/parallel/handler.go +++ b/libevm/precompiles/parallel/handler.go @@ -31,42 +31,55 @@ import ( // 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. +// 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. // -// All [libevm.StateReader] instances are opened to the state at the beginning -// of the block. The [StateDB] is the same one used to execute the block, -// before being committed, and MAY be written to. +// A [Processor] will orchestrate calling of Handler methods as follows: +// +// | - Prefetch(i) - Process(i) +// | / / +// | BeforeBlock() - PostProcess() - AfterBlock() +// | \ \ +// | - Prefetch(j) - Process(j) +// +// IntRA-Handler guarantees: // -// NOTE: other than [Handler.AfterBlock], all methods MAY be called concurrently -// with one another and with other [Handler] implementations, unless otherwise -// specified. AfterBlock() methods are called in the same order as they were -// registered with [AddHandler]. +// 1. BeforeBlock() precedes all Prefetch() calls. +// 2. Prefetch() precedes the respective Process() call. +// 3. PostProcess() precedes AfterBlock(). +// +// Note that PostProcess() MAY be called at any time, and implementations MUST +// synchronise using the [Results]. There are no intER-Handler guarantees except +// that AfterBlock() methods are called sequentially, in the same order as they +// were registered with [AddHandler]. +// +// All [libevm.StateReader] instances are opened to the state at the beginning +// of the block. The [StateDB] is the same one used to execute the block, before +// being committed, and MAY be written to. type Handler[CommonData, Data, Result, Aggregated any] interface { - // Gas reports whether the [Handler] SHOULD receive the transaction for + // Gas reports whether the Handler SHOULD receive the transaction for // processing and, if so, how much gas to charge. Processing is performed // i.f.f. the returned boolean is true and there is sufficient gas limit to - // cover intrinsic gas and all [Handler]s that returned true. If there is + // cover intrinsic gas and all Handlers that returned true. If there is // insufficient gas for processing then the transaction will result in // [vm.ErrOutOfGas] as long as the [Processor] is registered with // [vm.RegisterHooks] as a [vm.Preprocessor]. Gas(*types.Transaction) (gas uint64, process bool) - // BeforeBlock is called before all calls to Prefetch() on this [Handler], + // BeforeBlock is called before all calls to Prefetch() on this Handler, // all of which receive the returned value. BeforeBlock(libevm.StateReader, *types.Block) CommonData // Prefetch is called before the respective call to Process() on this - // [Handler]. It MUST NOT perform any meaningful computation beyond what is - // necessary to determine that necessary state to propagate to Process(). + // Handler. It MUST NOT perform any meaningful computation beyond what is + // necessary to determine the necessary state to propagate to Process(). Prefetch(libevm.StateReader, IndexedTx, CommonData) Data - // Process is responsible for performing all meaningful computation. It - // receives the common data returned by the single call to BeforeBlock() as - // well as the data from the respective call to Prefetch(). The returned - // result is propagated to PostProcess() and any calls to the function - // returned by [AddHandler]. + // Process is responsible for performing all meaningful, per-transaction + // computation. It receives the common data returned by the single call to + // BeforeBlock() as well as the data from the respective call to Prefetch(). + // The returned result is propagated to PostProcess() and any calls to the + // function returned by [AddHandler]. // // NOTE: if the result is exposed to the EVM via a precompile then said // precompile will block until Process() returns. While this guarantees the @@ -76,9 +89,14 @@ type Handler[CommonData, Data, Result, Aggregated any] interface { // PostProcess is called concurrently with all calls to Process(). It allows // for online aggregation of results into a format ready for writing to // state. + // + // NOTE: although PostProcess() MAY perform computation, it will block the + // calling of AfterBlock() and hence also the execution of the next block. PostProcess(Results[Result]) Aggregated // AfterBlock is called after PostProcess() returns and all regular EVM - // transaction processing is complete. + // transaction processing is complete. It MUST NOT perform any meaningful + // computation beyond what is necessary to (a) parse receipts, and (b) + // persist aggregated results. AfterBlock(StateDB, Aggregated, *types.Block, types.Receipts) } From 6b105ccd9abe6a6416ec8d803882977e3e78695b Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 3 Dec 2025 15:49:34 +0000 Subject: [PATCH 23/24] fix!: `BeforeBlock()` accepts `Header` to avoid access to txs --- libevm/precompiles/parallel/handler.go | 4 ++-- libevm/precompiles/parallel/parallel.go | 9 +-------- libevm/precompiles/parallel/parallel_test.go | 4 ++-- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/libevm/precompiles/parallel/handler.go b/libevm/precompiles/parallel/handler.go index 18f7af6e1ec4..bc2fc47e99d2 100644 --- a/libevm/precompiles/parallel/handler.go +++ b/libevm/precompiles/parallel/handler.go @@ -70,7 +70,7 @@ type Handler[CommonData, Data, Result, Aggregated any] interface { Gas(*types.Transaction) (gas uint64, process bool) // BeforeBlock is called before all calls to Prefetch() on this Handler, // all of which receive the returned value. - BeforeBlock(libevm.StateReader, *types.Block) CommonData + BeforeBlock(libevm.StateReader, *types.Header) CommonData // Prefetch is called before the respective call to Process() on this // Handler. It MUST NOT perform any meaningful computation beyond what is // necessary to determine the necessary state to propagate to Process(). @@ -187,7 +187,7 @@ func (w *wrapper[CD, D, R, A]) beforeBlock(sdb libevm.StateReader, b *types.Bloc go func() { // goroutine guaranteed to have completed by the time a respective // getter unblocks (i.e. in any call to [wrapper.prefetch]). - w.common.set(w.BeforeBlock(sdb, b)) + w.common.set(w.BeforeBlock(sdb, types.CopyHeader(b.Header()))) }() } diff --git a/libevm/precompiles/parallel/parallel.go b/libevm/precompiles/parallel/parallel.go index ea77f7932e03..975f85bf8045 100644 --- a/libevm/precompiles/parallel/parallel.go +++ b/libevm/precompiles/parallel/parallel.go @@ -184,14 +184,7 @@ func (p *Processor) StartBlock(sdb *state.StateDB, rules params.Rules, b *types. // reading from a [state.StateDB] is not threadsafe. p.stateShare.distribute(sdb) for _, h := range p.handlers { - h.beforeBlock( - sdb.Copy(), - types.NewBlockWithHeader( - b.Header(), - ).WithBody( - *b.Body(), - ), - ) + h.beforeBlock(sdb.Copy(), b) } txs := b.Transactions() diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 6a88f9870d48..8457222b76a9 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -71,9 +71,9 @@ type commonData struct { BeforeBlockStateVal common.Hash } -func (r *recorder) BeforeBlock(sdb libevm.StateReader, b *types.Block) commonData { +func (r *recorder) BeforeBlock(sdb libevm.StateReader, h *types.Header) commonData { return commonData{ - HeaderExtra: slices.Clone(b.Header().Extra), + HeaderExtra: slices.Clone(h.Extra), BeforeBlockStateVal: sdb.GetState(r.addr, r.blockKey), } } From 04136ba09b25efe637af3329f341760740e5636d Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 3 Dec 2025 16:02:06 +0000 Subject: [PATCH 24/24] feat: `Handler.PostProcess()` receives common data from `BeforeBlock`() --- libevm/precompiles/parallel/handler.go | 35 ++++++++-------- libevm/precompiles/parallel/parallel_test.go | 44 ++++++++++++-------- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/libevm/precompiles/parallel/handler.go b/libevm/precompiles/parallel/handler.go index bc2fc47e99d2..13d727a88bc3 100644 --- a/libevm/precompiles/parallel/handler.go +++ b/libevm/precompiles/parallel/handler.go @@ -51,10 +51,10 @@ import ( // 2. Prefetch() precedes the respective Process() call. // 3. PostProcess() precedes AfterBlock(). // -// Note that PostProcess() MAY be called at any time, and implementations MUST -// synchronise using the [Results]. There are no intER-Handler guarantees except -// that AfterBlock() methods are called sequentially, in the same order as they -// were registered with [AddHandler]. +// Note that PostProcess() MAY be called at any time after BeforeBlock(), and +// implementations MUST synchronise with Process() by using the [Results]. There +// are no intER-Handler guarantees except that AfterBlock() methods are called +// sequentially, in the same order as they were registered with [AddHandler]. // // All [libevm.StateReader] instances are opened to the state at the beginning // of the block. The [StateDB] is the same one used to execute the block, before @@ -92,7 +92,7 @@ type Handler[CommonData, Data, Result, Aggregated any] interface { // // NOTE: although PostProcess() MAY perform computation, it will block the // calling of AfterBlock() and hence also the execution of the next block. - PostProcess(Results[Result]) Aggregated + PostProcess(CommonData, Results[Result]) Aggregated // AfterBlock is called after PostProcess() returns and all regular EVM // transaction processing is complete. It MUST NOT perform any meaningful // computation beyond what is necessary to (a) parse receipts, and (b) @@ -191,7 +191,7 @@ func (w *wrapper[CD, D, R, A]) beforeBlock(sdb libevm.StateReader, b *types.Bloc }() } -func (w *wrapper[SD, D, R, A]) beforeWork(jobs int) { +func (w *wrapper[CD, D, R, A]) beforeWork(jobs int) { w.txsBeingProcessed.Add(jobs) w.whenProcessed = make(chan TxResult[R], jobs) go func() { @@ -200,11 +200,11 @@ func (w *wrapper[SD, D, R, A]) beforeWork(jobs int) { }() } -func (w *wrapper[SD, D, R, A]) prefetch(sdb libevm.StateReader, job *job) { +func (w *wrapper[CD, D, R, A]) prefetch(sdb libevm.StateReader, job *job) { w.data[job.tx.Index].set(w.Prefetch(sdb, job.tx, w.common.getAndReplace())) } -func (w *wrapper[SD, D, R, A]) process(sdb libevm.StateReader, job *job) { +func (w *wrapper[CD, D, R, A]) process(sdb libevm.StateReader, job *job) { defer w.txsBeingProcessed.Done() idx := job.tx.Index @@ -220,14 +220,14 @@ func (w *wrapper[SD, D, R, A]) process(sdb libevm.StateReader, job *job) { } } -func (w *wrapper[SD, D, R, A]) nullResult(job *job) { +func (w *wrapper[CD, D, R, A]) nullResult(job *job) { w.results[job.tx.Index].set(result[R]{ tx: job.tx, val: nil, }) } -func (w *wrapper[SD, D, R, A]) result(i int) (TxResult[R], bool) { +func (w *wrapper[CD, D, R, A]) result(i int) (TxResult[R], bool) { r := w.results[i].getAndReplace() txr := TxResult[R]{ @@ -242,7 +242,7 @@ func (w *wrapper[SD, D, R, A]) result(i int) (TxResult[R], bool) { return txr, true } -func (w *wrapper[SD, D, R, A]) postProcess() { +func (w *wrapper[CD, D, R, A]) postProcess() { txOrder := make(chan TxResult[R], w.totalTxsInBLock) go func() { defer close(txOrder) @@ -255,17 +255,18 @@ func (w *wrapper[SD, D, R, A]) postProcess() { } }() - w.aggregated.set(w.PostProcess(Results[R]{ + res := Results[R]{ WaitForAll: w.txsBeingProcessed.Wait, TxOrder: txOrder, ProcessOrder: w.whenProcessed, - })) + } + w.aggregated.set(w.PostProcess(w.common.getAndReplace(), res)) } -func (p *wrapper[SD, D, R, A]) finishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { - p.AfterBlock(sdb, p.aggregated.getAndKeep(), b, rs) - p.common.getAndKeep() - for _, v := range p.results[:p.totalTxsInBLock] { +func (w *wrapper[CD, D, R, A]) finishBlock(sdb vm.StateDB, b *types.Block, rs types.Receipts) { + w.AfterBlock(sdb, w.aggregated.getAndKeep(), b, rs) + w.common.getAndKeep() + for _, v := range w.results[:w.totalTxsInBLock] { // 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. diff --git a/libevm/precompiles/parallel/parallel_test.go b/libevm/precompiles/parallel/parallel_test.go index 8457222b76a9..a9fb55201014 100644 --- a/libevm/precompiles/parallel/parallel_test.go +++ b/libevm/precompiles/parallel/parallel_test.go @@ -62,8 +62,9 @@ type aggregated struct { } type recorded struct { - HeaderExtra, TxData []byte - Block, Prefetch, Process common.Hash + TxData []byte + Prefetch, Process common.Hash + Common commonData } type commonData struct { @@ -103,11 +104,10 @@ func (r *recorder) Process(sdb libevm.StateReader, tx IndexedTx, cd commonData, } return recorded{ - HeaderExtra: slices.Clone(cd.HeaderExtra), - TxData: slices.Clone(tx.Data()), - Block: cd.BeforeBlockStateVal, - Prefetch: data.prefetchStateVal, - Process: sdb.GetState(r.addr, r.processKey), + TxData: slices.Clone(tx.Data()), + Prefetch: data.prefetchStateVal, + Process: sdb.GetState(r.addr, r.processKey), + Common: cd, } } @@ -121,16 +121,16 @@ func (r recorded) PrecompileOutput(env vm.PrecompileEnvironment, input []byte) ( } func (r recorded) precompileReturnData() []byte { - return slices.Concat(r.HeaderExtra, []byte("|"), r.TxData) + return slices.Concat(r.Common.HeaderExtra, []byte("|"), r.TxData) } func (r recorded) asLog() *types.Log { return &types.Log{ - Topics: []common.Hash{r.Block, r.Prefetch, r.Process}, + Topics: []common.Hash{r.Common.BeforeBlockStateVal, r.Prefetch, r.Process}, } } -func (r *recorder) PostProcess(res Results[recorded]) aggregated { +func (r *recorder) PostProcess(cd commonData, res Results[recorded]) aggregated { // Although unnecessary because of the ranging over both channels, this just // demonstrates that it's non-blocking. defer res.WaitForAll() @@ -143,6 +143,12 @@ func (r *recorder) PostProcess(res Results[recorded]) aggregated { out.processOrder = append(out.processOrder, res) } + if len(out.txOrder) > 0 { + if diff := cmp.Diff(cd, out.txOrder[0].Result.Common); diff != "" { + r.tb.Errorf("Mismatched CommonData propagation to Handler methods; diff (-PostProcess, +Process):\n%s", diff) + } + } + return out } @@ -270,11 +276,13 @@ func TestProcessor(t *testing.T) { var want recorded if wantOK { want = recorded{ - HeaderExtra: extra, - Block: blockVal, - Prefetch: prefetchVal, - Process: processVal, - TxData: tx.Data(), + Common: commonData{ + HeaderExtra: extra, + BeforeBlockStateVal: blockVal, + }, + Prefetch: prefetchVal, + Process: processVal, + TxData: tx.Data(), } wantPerTx = append(wantPerTx, TxResult[recorded]{ Tx: IndexedTx{ @@ -424,8 +432,10 @@ func TestIntegration(t *testing.T) { wantReturnData = append(wantReturnData, []byte{}) } else { rec := &recorded{ - HeaderExtra: slices.Clone(header.Extra), - TxData: tx.Data(), + Common: commonData{ + HeaderExtra: slices.Clone(header.Extra), + }, + TxData: tx.Data(), } wantReturnData = append(wantReturnData, rec.precompileReturnData())