go-ethereum/libevm/precompiles/parallel/parallel_test.go
Arran Schlosberg 98a792673a
feat: parallel package for precompile pre-processing (#228)
## Why this should be merged

EVM parallel co-processors that interface with the regular transaction
path via precompiles.

## How this works

Introduces the `parallel.Processor`, which orchestrates a set of
`parallel.Handler`s. Each `Handler` performs arbitrary, strongly typed
processing of any sub-set of transactions in a block and makes its
results available to a precompile and/or a post-block method for
persisting state. Although stateful, `Handler`s can only read the
pre-block and post-block state, which isolates them from conflicts with
the regular transaction path.

There is deliberately no support for a precompile to "write" to a
`Handler`, only to "read". This is because the transaction might still
revert, which would also have to be communicated to the `Handler`,
resulting in unnecessary complexity. Logs/events are the recommended
approach for precompile -> `Handler` communication, to be read from the
`types.Receipts` at the end of the block.

## How this was tested

Integration tests covering:

1. Selection of transactions to process + end-to-end plumbing of data
through a `Handler`.
2. Registration as a precompile, exercised with actual transaction
processing, and demonstrating log + return-data correctness.

---------

Signed-off-by: Arran Schlosberg <519948+ARR4N@users.noreply.github.com>
2026-02-18 12:02:13 +00:00

576 lines
15 KiB
Go

// Copyright 2025-2026 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
// <http://www.gnu.org/licenses/>.
package parallel
import (
"encoding/binary"
"fmt"
"math"
"math/big"
"math/rand/v2"
"slices"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/holiman/uint256"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core"
"github.com/ava-labs/libevm/core/types"
"github.com/ava-labs/libevm/core/vm"
"github.com/ava-labs/libevm/crypto"
"github.com/ava-labs/libevm/libevm"
"github.com/ava-labs/libevm/libevm/ethtest"
"github.com/ava-labs/libevm/libevm/hookstest"
"github.com/ava-labs/libevm/params"
"github.com/ava-labs/libevm/trie"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m, goleak.IgnoreCurrent())
}
type recorder struct {
tb testing.TB
gas uint64
addr common.Address
blockKey, prefetchKey, processKey common.Hash
gotReceipts types.Receipts
gotAggregated aggregated
}
type aggregated struct {
txOrder, processOrder []TxResult[recorded]
}
type recorded struct {
TxData []byte
Prefetch, Process common.Hash
Common commonData
}
type commonData struct {
HeaderExtra []byte
BeforeBlockStateVal common.Hash
}
func (r *recorder) BeforeBlock(sdb libevm.StateReader, h *types.Header) commonData {
return commonData{
HeaderExtra: slices.Clone(h.Extra),
BeforeBlockStateVal: sdb.GetState(r.addr, r.blockKey),
}
}
func (r *recorder) ShouldProcess(tx IndexedTx, _ commonData) (bool, uint64) {
// TODO(arr4n) test that the [commonData] received here is the same as that
// returned by [recorder.BeforeBlock].
if to := tx.To(); to != nil && *to == r.addr {
return true, r.gas
}
return false, 0
}
type prefetched struct {
prefetchStateVal common.Hash
common commonData
}
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, 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{
TxData: slices.Clone(tx.Data()),
Prefetch: data.prefetchStateVal,
Process: sdb.GetState(r.addr, r.processKey),
Common: cd,
}
}
var _ PrecompileResult = recorded{}
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 {
return slices.Concat(r.Common.HeaderExtra, []byte("|"), r.TxData)
}
func (r recorded) asLog() *types.Log {
return &types.Log{
Topics: []common.Hash{r.Common.BeforeBlockStateVal, r.Prefetch, r.Process},
}
}
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()
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)
}
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
}
func (r *recorder) AfterBlock(_ StateDB, agg aggregated, _ *types.Block, rs types.Receipts) {
r.gotReceipts = slices.Clone(rs)
r.gotAggregated = agg
}
func asHash(s string) (h common.Hash) {
copy(h[:], []byte(s))
return
}
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(8, 8)
getResult := AddHandler(p, handler)
t.Cleanup(p.Close)
type blockParams struct {
numTxs int
sendToAddrEvery, sufficientGasEvery int
}
// Each set of params is effectively a test case, but they are all run on
// the same [Processor].
tests := []blockParams{
{
numTxs: 0,
},
{
numTxs: 500,
sendToAddrEvery: 7,
sufficientGasEvery: 5,
},
{
numTxs: 1_000,
sendToAddrEvery: 7,
sufficientGasEvery: 5,
},
{
numTxs: 1_000,
sendToAddrEvery: 11,
sufficientGasEvery: 3,
},
{
numTxs: 100,
sendToAddrEvery: 1,
sufficientGasEvery: 1,
},
{
numTxs: 0,
},
}
rng := rand.New(rand.NewPCG(0, 0)) //nolint:gosec // Reproducibility is useful for testing
for range 100 {
tests = append(tests, blockParams{
numTxs: rng.IntN(1000),
sendToAddrEvery: 1 + rng.IntN(30),
sufficientGasEvery: 1 + rng.IntN(30),
})
}
_, _, sdb := ethtest.NewEmptyStateDB(t)
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) {
t.Logf("%+v", tt)
var rules params.Rules
txs := make(types.Transactions, tt.numTxs)
wantProcessed := make([]bool, tt.numTxs)
for i := range len(txs) {
var (
to common.Address
extraGas uint64
)
wantProcessed[i] = true
if i%tt.sendToAddrEvery == 0 {
to = handler.addr
} else {
wantProcessed[i] = false
}
if i%tt.sufficientGasEvery == 0 {
extraGas = handler.gas
} else {
wantProcessed[i] = false
}
data := binary.BigEndian.AppendUint64(nil, uint64(i)) //nolint:gosec // Known to be positive
gas, err := intrinsicGas(data, types.AccessList{}, &handler.addr, &rules)
require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data)
txs[i] = types.NewTx(&types.LegacyTx{
To: &to,
Data: data,
Gas: gas + extraGas,
})
}
extra := []byte("extra")
block := types.NewBlock(&types.Header{Extra: extra}, txs, nil, nil, trie.NewStackTrie(nil))
require.NoError(t, p.StartBlock(sdb, rules, block), "StartBlock()")
var wantPerTx []TxResult[recorded]
for i, tx := range txs {
wantOK := wantProcessed[i]
var want recorded
if wantOK {
want = recorded{
Common: commonData{
HeaderExtra: extra,
BeforeBlockStateVal: blockVal,
},
Prefetch: prefetchVal,
Process: processVal,
TxData: tx.Data(),
}
wantPerTx = append(wantPerTx, TxResult[recorded]{
Tx: IndexedTx{
Index: i,
Transaction: tx,
},
Result: want,
})
}
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.Result); diff != "" {
t.Errorf("Result(%d) diff (-want +got):\n%s", i, diff)
}
}
p.FinishBlock(sdb, block, nil)
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)
}
})
}
})
if t.Failed() {
break
}
}
}
type vmHooks struct {
vm.Preprocessor // the [Processor]
vm.NOOPHooks
}
func (h *vmHooks) PreprocessingGasCharge(tx common.Hash) (uint64, error) {
return h.Preprocessor.PreprocessingGasCharge(tx)
}
func TestIntegration(t *testing.T) {
const handlerGas = 500
handler := &recorder{
tb: t,
addr: common.Address{'c', 'o', 'n', 'c', 'a', 't'},
gas: handlerGas,
}
sut := New(8, 8)
precompile := AddAsPrecompile(sut, handler)
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(precompile),
},
}
stub.Register(t)
key, err := crypto.GenerateKey()
require.NoErrorf(t, err, "crypto.GenerateKey()")
eoa := crypto.PubkeyToAddress(key.PublicKey)
state, evm := ethtest.NewZeroEVM(t)
state.CreateAccount(eoa)
state.SetBalance(eoa, new(uint256.Int).SetAllOne())
var (
txs types.Transactions
wantReturnData [][]byte
wantReceipts types.Receipts
)
ignore := cmp.Options{
cmpopts.IgnoreFields(
types.Receipt{},
"PostState", "CumulativeGasUsed", "BlockNumber", "BlockHash", "Bloom",
),
cmpopts.IgnoreFields(types.Log{}, "BlockHash"),
}
header := &types.Header{
Number: big.NewInt(0),
BaseFee: big.NewInt(0),
}
config := evm.ChainConfig()
rules := config.Rules(header.Number, true, header.Time)
signer := types.MakeSigner(config, header.Number, header.Time)
for i, addr := range []common.Address{
{'o', 't', 'h', 'e', 'r'},
handler.addr,
} {
ui := uint(i) //nolint:gosec // Known to be positive
data := []byte("hello, world")
gas, err := intrinsicGas(data, types.AccessList{}, &addr, &rules)
require.NoError(t, err, "core.IntrinsicGas(%#x, nil, false, ...)", data)
if addr == handler.addr {
gas += handlerGas
}
tx := types.MustSignNewTx(key, signer, &types.LegacyTx{
Nonce: uint64(ui),
To: &addr,
Data: data,
Gas: gas,
})
txs = append(txs, tx)
wantR := &types.Receipt{
Status: types.ReceiptStatusSuccessful,
TxHash: tx.Hash(),
GasUsed: gas,
TransactionIndex: ui,
}
if addr != handler.addr {
wantReturnData = append(wantReturnData, []byte{})
} else {
rec := &recorded{
Common: commonData{
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}
}
wantReceipts = append(wantReceipts, wantR)
}
block := types.NewBlock(header, txs, nil, nil, trie.NewStackTrie(nil))
require.NoError(t, sut.StartBlock(state, rules, block), "StartBlock()")
pool := core.GasPool(math.MaxUint64)
var receipts types.Receipts
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(),
ethtest.DummyChainContext(),
&block.Header().Coinbase,
&pool,
state,
block.Header(),
tx,
&usedGas,
vm.Config{},
)
require.NoError(t, err, "ApplyTransaction([%d])", i)
receipts = append(receipts, receipt)
}
sut.FinishBlock(state, block, receipts)
if diff := cmp.Diff(wantReceipts, handler.gotReceipts, ignore); diff != "" {
t.Errorf("%T diff (-want +got):\n%s", receipts, diff)
}
}
type expensive struct {
gasCost uint64
}
func (expensive) BeforeBlock(libevm.StateReader, *types.Header) int { return 0 }
func (e expensive) ShouldProcess(IndexedTx, int) (do bool, gas uint64) { return true, e.gasCost }
func (expensive) Prefetch(libevm.StateReader, IndexedTx, int) int { return 0 }
func (expensive) Process(libevm.StateReader, IndexedTx, int, int) int { return 0 }
func (expensive) PostProcess(int, Results[int]) int { return 0 }
func (expensive) AfterBlock(StateDB, int, *types.Block, types.Receipts) {}
func TestTotalCost(t *testing.T) {
tx := types.NewTx(&types.LegacyTx{
To: &common.Address{},
Gas: params.TxGas,
})
b := types.NewBlock(
&types.Header{Number: big.NewInt(0)},
types.Transactions{tx},
nil, nil,
trie.NewStackTrie(nil),
)
rules := params.MergedTestChainConfig.Rules(big.NewInt(0), true, 0)
_, _, sdb := ethtest.NewEmptyStateDB(t)
tests := []struct {
costs []uint64
want uint64
}{
{
costs: []uint64{1},
want: 1,
},
{
costs: []uint64{1, 0},
want: 1,
},
{
costs: []uint64{1, 1},
want: 2,
},
{
costs: []uint64{math.MaxUint64 - 42, 41},
want: math.MaxUint64 - 1,
},
{
costs: []uint64{math.MaxUint64 - 42, 43},
want: math.MaxUint64,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.costs), func(t *testing.T) {
p := New(1, 1)
t.Cleanup(p.Close)
for _, c := range tt.costs {
AddHandler(p, expensive{gasCost: c})
}
require.NoError(t, p.StartBlock(sdb, rules, b), "StartBlock()")
t.Cleanup(func() { p.FinishBlock(sdb, b, nil) })
got, err := p.PreprocessingGasCharge(tx.Hash())
if err != nil || got != tt.want {
t.Errorf("PreprocessingGasCharge() got (%d, %v); want (%d, nil)", got, err, tt.want)
}
})
}
}
// TODO(arr4n) unit test for [AddPrecompile] unhappy paths.