diff --git a/cmd/evm/blockrunner.go b/cmd/evm/blockrunner.go
index c6fac5396e0..2bbbddd6116 100644
--- a/cmd/evm/blockrunner.go
+++ b/cmd/evm/blockrunner.go
@@ -24,6 +24,7 @@ import (
"os"
"regexp"
"slices"
+ "sync"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
@@ -44,6 +45,7 @@ var blockTestCommand = &cli.Command{
RunFlag,
WitnessCrossCheckFlag,
FuzzFlag,
+ WorkersFlag,
}, traceFlags),
}
@@ -52,16 +54,14 @@ func blockTestCmd(ctx *cli.Context) error {
// If path is provided, run the tests at that path.
if len(path) != 0 {
- var (
- collected = collectFiles(path)
- results []testResult
- )
- for _, fname := range collected {
- r, err := runBlockTest(ctx, fname)
- if err != nil {
- return err
- }
- results = append(results, r...)
+ collected := collectFiles(path)
+ workers := ctx.Int(WorkersFlag.Name)
+ if workers <= 0 {
+ workers = 1
+ }
+ results, err := runBlockTestsParallel(ctx, collected, workers)
+ if err != nil {
+ return err
}
report(ctx, results)
return nil
@@ -85,6 +85,63 @@ func blockTestCmd(ctx *cli.Context) error {
return nil
}
+func runBlockTestsParallel(ctx *cli.Context, files []string, workers int) ([]testResult, error) {
+ if workers == 1 {
+ var results []testResult
+ for _, fname := range files {
+ r, err := runBlockTest(ctx, fname)
+ if err != nil {
+ return nil, err
+ }
+ results = append(results, r...)
+ }
+ return results, nil
+ }
+ var (
+ wg sync.WaitGroup
+ fileCh = make(chan struct {
+ index int
+ fname string
+ }, len(files))
+ resultCh = make(chan fileResult, len(files))
+ )
+ for i, fname := range files {
+ fileCh <- struct {
+ index int
+ fname string
+ }{i, fname}
+ }
+ close(fileCh)
+
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for item := range fileCh {
+ r, err := runBlockTest(ctx, item.fname)
+ resultCh <- fileResult{index: item.index, results: r, err: err}
+ }
+ }()
+ }
+ go func() {
+ wg.Wait()
+ close(resultCh)
+ }()
+
+ ordered := make([]fileResult, len(files))
+ for fr := range resultCh {
+ if fr.err != nil {
+ return nil, fr.err
+ }
+ ordered[fr.index] = fr
+ }
+ var results []testResult
+ for _, fr := range ordered {
+ results = append(results, fr.results...)
+ }
+ return results, nil
+}
+
func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) {
src, err := os.ReadFile(fname)
if err != nil {
@@ -92,7 +149,7 @@ func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) {
}
var tests map[string]*tests.BlockTest
if err = json.Unmarshal(src, &tests); err != nil {
- return nil, err
+ return nil, nil // Skip non-fixture JSON files
}
re, err := regexp.Compile(ctx.String(RunFlag.Name))
if err != nil {
@@ -116,17 +173,16 @@ func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) {
}
test := tests[name]
result := &testResult{Name: name, Pass: true}
- var finalRoot *common.Hash
+ var finalHash *common.Hash
if err := test.Run(false, rawdb.PathScheme, ctx.Bool(WitnessCrossCheckFlag.Name), tracer, func(res error, chain *core.BlockChain) {
if ctx.Bool(DumpFlag.Name) {
if s, _ := chain.State(); s != nil {
result.State = dump(s)
}
}
- // Capture final state root for end marker
if chain != nil {
- root := chain.CurrentBlock().Root
- finalRoot = &root
+ hash := chain.CurrentBlock().Hash()
+ finalHash = &hash
}
}); err != nil {
result.Pass, result.Error = false, err.Error()
@@ -134,9 +190,11 @@ func runBlockTest(ctx *cli.Context, fname string) ([]testResult, error) {
// Always assign fork (regardless of pass/fail or tracer)
result.Fork = test.Network()
- // Assign root if test succeeded
- if result.Pass && finalRoot != nil {
- result.Root = finalRoot
+ if finalHash != nil {
+ result.BlockHash = finalHash
+ }
+ if result.Pass && test.LastBlockError != "" {
+ result.Error = test.LastBlockError
}
// When fuzzing, write results after every block
diff --git a/cmd/evm/enginerunner.go b/cmd/evm/enginerunner.go
new file mode 100644
index 00000000000..3a96cc7e05f
--- /dev/null
+++ b/cmd/evm/enginerunner.go
@@ -0,0 +1,225 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of go-ethereum.
+//
+// go-ethereum is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// go-ethereum is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with go-ethereum. If not, see .
+
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "maps"
+ "os"
+ "regexp"
+ "runtime"
+ "slices"
+ "sync"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/tests"
+ "github.com/urfave/cli/v2"
+)
+
+var (
+ WorkersFlag = &cli.IntFlag{
+ Name: "workers",
+ Usage: "Number of parallel workers for processing fixture files",
+ Value: 1,
+ }
+)
+
+var engineTestCommand = &cli.Command{
+ Action: engineTestCmd,
+ Name: "enginetest",
+ Usage: "Executes the given engine API tests. Filenames can be fed via standard input (batch mode) or as an argument (one-off execution).",
+ ArgsUsage: "",
+ Flags: slices.Concat([]cli.Flag{
+ DumpFlag,
+ HumanReadableFlag,
+ RunFlag,
+ FuzzFlag,
+ WorkersFlag,
+ }, traceFlags),
+}
+
+func engineTestCmd(ctx *cli.Context) error {
+ path := ctx.Args().First()
+
+ // If path is provided, run the tests at that path.
+ if len(path) != 0 {
+ collected := collectFiles(path)
+ workers := ctx.Int(WorkersFlag.Name)
+ if workers <= 0 {
+ workers = runtime.NumCPU()
+ }
+ results, err := runEngineTestsParallel(ctx, collected, workers)
+ if err != nil {
+ return err
+ }
+ report(ctx, results)
+ return nil
+ }
+ // Otherwise, read filenames from stdin and execute back-to-back.
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ fname := scanner.Text()
+ if len(fname) == 0 {
+ return nil
+ }
+ results, err := runEngineTest(ctx, fname)
+ if err != nil {
+ return err
+ }
+ if !ctx.IsSet(FuzzFlag.Name) {
+ report(ctx, results)
+ }
+ }
+ return nil
+}
+
+// fileResult holds the results from processing a single fixture file.
+type fileResult struct {
+ index int
+ results []testResult
+ err error
+}
+
+// runEngineTestsParallel processes fixture files using a worker pool.
+func runEngineTestsParallel(ctx *cli.Context, files []string, workers int) ([]testResult, error) {
+ if workers == 1 {
+ // Fast path: no goroutine overhead for single worker
+ var results []testResult
+ for _, fname := range files {
+ r, err := runEngineTest(ctx, fname)
+ if err != nil {
+ return nil, err
+ }
+ results = append(results, r...)
+ }
+ return results, nil
+ }
+ // Parallel execution
+ var (
+ wg sync.WaitGroup
+ fileCh = make(chan struct {
+ index int
+ fname string
+ }, len(files))
+ resultCh = make(chan fileResult, len(files))
+ )
+ // Feed files into the channel
+ for i, fname := range files {
+ fileCh <- struct {
+ index int
+ fname string
+ }{i, fname}
+ }
+ close(fileCh)
+
+ // Start workers
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for item := range fileCh {
+ r, err := runEngineTest(ctx, item.fname)
+ resultCh <- fileResult{index: item.index, results: r, err: err}
+ }
+ }()
+ }
+ // Close result channel when all workers are done
+ go func() {
+ wg.Wait()
+ close(resultCh)
+ }()
+
+ // Collect results in order
+ ordered := make([]fileResult, len(files))
+ for fr := range resultCh {
+ if fr.err != nil {
+ return nil, fr.err
+ }
+ ordered[fr.index] = fr
+ }
+ var results []testResult
+ for _, fr := range ordered {
+ results = append(results, fr.results...)
+ }
+ return results, nil
+}
+
+func runEngineTest(ctx *cli.Context, fname string) ([]testResult, error) {
+ src, err := os.ReadFile(fname)
+ if err != nil {
+ return nil, err
+ }
+ var testsByName map[string]*tests.EngineTest
+ if err = json.Unmarshal(src, &testsByName); err != nil {
+ // Skip non-fixture JSON files (e.g. .meta/index.json)
+ return nil, nil
+ }
+ re, err := regexp.Compile(ctx.String(RunFlag.Name))
+ if err != nil {
+ return nil, fmt.Errorf("invalid regex -%s: %v", RunFlag.Name, err)
+ }
+ tracer := tracerFromFlags(ctx)
+
+ if ctx.IsSet(FuzzFlag.Name) {
+ log.SetDefault(log.NewLogger(log.DiscardHandler()))
+ }
+
+ keys := slices.Sorted(maps.Keys(testsByName))
+
+ var results []testResult
+ for _, name := range keys {
+ if !re.MatchString(name) {
+ continue
+ }
+ test := testsByName[name]
+ result := &testResult{Name: name, Pass: true}
+ var finalHash *common.Hash
+ if err := test.Run(rawdb.PathScheme, tracer, func(res error, chain *core.BlockChain) {
+ if ctx.Bool(DumpFlag.Name) {
+ if s, _ := chain.State(); s != nil {
+ result.State = dump(s)
+ }
+ }
+ if chain != nil {
+ hash := chain.CurrentBlock().Hash()
+ finalHash = &hash
+ }
+ }); err != nil {
+ result.Pass, result.Error = false, err.Error()
+ }
+
+ result.Fork = test.Network()
+ if finalHash != nil {
+ result.BlockHash = finalHash
+ }
+ result.PayloadStatus = test.LastPayloadStatus
+ if result.Pass && test.LastValidationError != "" {
+ result.Error = test.LastValidationError
+ }
+
+ if ctx.IsSet(FuzzFlag.Name) {
+ report(ctx, []testResult{*result})
+ }
+ results = append(results, *result)
+ }
+ return results, nil
+}
diff --git a/cmd/evm/main.go b/cmd/evm/main.go
index 77a06bec26f..f9c4dcd77d9 100644
--- a/cmd/evm/main.go
+++ b/cmd/evm/main.go
@@ -75,6 +75,10 @@ var (
Name: "human",
Usage: "\"Human-readable\" output",
}
+ NDJSONFlag = &cli.BoolFlag{
+ Name: "ndjson",
+ Usage: "Output one JSON result per line as tests complete (streaming)",
+ }
StatDumpFlag = &cli.BoolFlag{
Name: "statdump",
Usage: "displays stack and heap memory information",
@@ -259,6 +263,7 @@ func init() {
app.Commands = []*cli.Command{
runCommand,
blockTestCommand,
+ engineTestCommand,
stateTestCommand,
stateTransitionCommand,
transactionCommand,
diff --git a/cmd/evm/reporter.go b/cmd/evm/reporter.go
index f6249e18436..8bbcc6b2983 100644
--- a/cmd/evm/reporter.go
+++ b/cmd/evm/reporter.go
@@ -33,13 +33,15 @@ const (
// testResult contains the execution status after running a state test, any
// error that might have occurred and a dump of the final state if requested.
type testResult struct {
- Name string `json:"name"`
- Pass bool `json:"pass"`
- Root *common.Hash `json:"stateRoot,omitempty"`
- Fork string `json:"fork"`
- Error string `json:"error,omitempty"`
- State *state.Dump `json:"state,omitempty"`
- Stats *execStats `json:"benchStats,omitempty"`
+ Name string `json:"name"`
+ Pass bool `json:"pass"`
+ Root *common.Hash `json:"stateRoot,omitempty"`
+ Fork string `json:"fork"`
+ Error string `json:"error"`
+ BlockHash *common.Hash `json:"lastBlockHash,omitempty"`
+ PayloadStatus string `json:"lastPayloadStatus,omitempty"`
+ State *state.Dump `json:"state,omitempty"`
+ Stats *execStats `json:"benchStats,omitempty"`
}
func (r testResult) String() string {
@@ -85,3 +87,9 @@ func report(ctx *cli.Context, results []testResult) {
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
}
+
+// reportNDJSON prints one JSON object per result as it completes.
+func reportNDJSON(r testResult) {
+ out, _ := json.Marshal(r)
+ fmt.Println(string(out))
+}
diff --git a/cmd/evm/staterunner.go b/cmd/evm/staterunner.go
index 1b0eb2ca2ac..549275669fc 100644
--- a/cmd/evm/staterunner.go
+++ b/cmd/evm/staterunner.go
@@ -23,6 +23,7 @@ import (
"os"
"regexp"
"slices"
+ "sync"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/rawdb"
@@ -57,6 +58,7 @@ var stateTestCommand = &cli.Command{
HumanReadableFlag,
idxFlag,
RunFlag,
+ WorkersFlag,
}, traceFlags),
}
@@ -65,16 +67,14 @@ func stateTestCmd(ctx *cli.Context) error {
// If path is provided, run the tests at that path.
if len(path) != 0 {
- var (
- collected = collectFiles(path)
- results []testResult
- )
- for _, fname := range collected {
- r, err := runStateTest(ctx, fname)
- if err != nil {
- return err
- }
- results = append(results, r...)
+ collected := collectFiles(path)
+ workers := ctx.Int(WorkersFlag.Name)
+ if workers <= 0 {
+ workers = 1
+ }
+ results, err := runStateTestsParallel(ctx, collected, workers)
+ if err != nil {
+ return err
}
report(ctx, results)
return nil
@@ -95,6 +95,63 @@ func stateTestCmd(ctx *cli.Context) error {
return nil
}
+func runStateTestsParallel(ctx *cli.Context, files []string, workers int) ([]testResult, error) {
+ if workers == 1 {
+ var results []testResult
+ for _, fname := range files {
+ r, err := runStateTest(ctx, fname)
+ if err != nil {
+ return nil, err
+ }
+ results = append(results, r...)
+ }
+ return results, nil
+ }
+ var (
+ wg sync.WaitGroup
+ fileCh = make(chan struct {
+ index int
+ fname string
+ }, len(files))
+ resultCh = make(chan fileResult, len(files))
+ )
+ for i, fname := range files {
+ fileCh <- struct {
+ index int
+ fname string
+ }{i, fname}
+ }
+ close(fileCh)
+
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for item := range fileCh {
+ r, err := runStateTest(ctx, item.fname)
+ resultCh <- fileResult{index: item.index, results: r, err: err}
+ }
+ }()
+ }
+ go func() {
+ wg.Wait()
+ close(resultCh)
+ }()
+
+ ordered := make([]fileResult, len(files))
+ for fr := range resultCh {
+ if fr.err != nil {
+ return nil, fr.err
+ }
+ ordered[fr.index] = fr
+ }
+ var results []testResult
+ for _, fr := range ordered {
+ results = append(results, fr.results...)
+ }
+ return results, nil
+}
+
// runStateTest loads the state-test given by fname, and executes the test.
func runStateTest(ctx *cli.Context, fname string) ([]testResult, error) {
src, err := os.ReadFile(fname)
@@ -103,7 +160,7 @@ func runStateTest(ctx *cli.Context, fname string) ([]testResult, error) {
}
var testsByName map[string]tests.StateTest
if err := json.Unmarshal(src, &testsByName); err != nil {
- return nil, fmt.Errorf("unable to read test file %s: %w", fname, err)
+ return nil, nil // Skip non-fixture JSON files
}
cfg := vm.Config{Tracer: tracerFromFlags(ctx)}
@@ -153,6 +210,9 @@ func runStateTest(ctx *cli.Context, fname string) ([]testResult, error) {
result.Pass, result.Error = false, err.Error()
return
}
+ if test.LastTxError != "" {
+ result.Error = test.LastTxError
+ }
})
results = append(results, *result)
}
diff --git a/tests/block_test_util.go b/tests/block_test_util.go
index 00411073e20..a8587fe976e 100644
--- a/tests/block_test_util.go
+++ b/tests/block_test_util.go
@@ -48,7 +48,8 @@ import (
// A BlockTest checks handling of entire blocks.
type BlockTest struct {
- json btJSON
+ json btJSON
+ LastBlockError string // actual error from rejected blocks, for result reporting
}
// UnmarshalJSON implements json.Unmarshaler interface.
@@ -250,6 +251,7 @@ func (t *BlockTest) insertBlocks(blockchain *core.BlockChain) ([]btBlock, error)
cb, err := b.decode()
if err != nil {
if b.BlockHeader == nil {
+ t.LastBlockError = err.Error()
log.Info("Block decoding failed", "index", bi, "err", err)
continue // OK - block is supposed to be invalid, continue with next block
} else {
@@ -261,6 +263,7 @@ func (t *BlockTest) insertBlocks(blockchain *core.BlockChain) ([]btBlock, error)
i, err := blockchain.InsertChain(blocks)
if err != nil {
if b.BlockHeader == nil {
+ t.LastBlockError = err.Error()
continue // OK - block is supposed to be invalid, continue with next block
} else {
return nil, fmt.Errorf("block #%v insertion into chain failed: %v", blocks[i].Number(), err)
@@ -268,7 +271,7 @@ func (t *BlockTest) insertBlocks(blockchain *core.BlockChain) ([]btBlock, error)
}
if b.BlockHeader == nil {
if data, err := json.MarshalIndent(cb.Header(), "", " "); err == nil {
- fmt.Fprintf(os.Stdout, "block (index %d) insertion should have failed due to: %v:\n%v\n",
+ fmt.Fprintf(os.Stderr, "block (index %d) insertion should have failed due to: %v:\n%v\n",
bi, b.ExpectException, string(data))
}
return nil, fmt.Errorf("block (index %d) insertion should have failed due to: %v",
diff --git a/tests/engine_test_util.go b/tests/engine_test_util.go
new file mode 100644
index 00000000000..4d6aac58be0
--- /dev/null
+++ b/tests/engine_test_util.go
@@ -0,0 +1,628 @@
+// Copyright 2025 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library is free software: you can redistribute it and/or modify
+// it 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 go-ethereum library is distributed in the hope that it 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 tests
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ stdmath "math"
+ "math/big"
+ "strconv"
+
+ "github.com/ethereum/go-ethereum/beacon/engine"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ "github.com/ethereum/go-ethereum/consensus/beacon"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/tracing"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/params/forks"
+ "github.com/ethereum/go-ethereum/triedb"
+ "github.com/ethereum/go-ethereum/triedb/hashdb"
+ "github.com/ethereum/go-ethereum/triedb/pathdb"
+)
+
+// EngineTest checks processing of engine API payloads.
+type EngineTest struct {
+ json etJSON
+ LastPayloadStatus string // set during Run, exposed for the runner
+ LastValidationError string // actual validation error from engine
+}
+
+func (t *EngineTest) UnmarshalJSON(in []byte) error {
+ return json.Unmarshal(in, &t.json)
+}
+
+// Network returns the network/fork name for this test.
+func (t *EngineTest) Network() string {
+ return t.json.Network
+}
+
+type etJSON struct {
+ Genesis btHeader `json:"genesisBlockHeader"`
+ Pre types.GenesisAlloc `json:"pre"`
+ Post types.GenesisAlloc `json:"postState"`
+ PostHash *common.UnprefixedHash `json:"postStateHash"`
+ BestBlock common.UnprefixedHash `json:"lastblockhash"`
+ Network string `json:"network"`
+ Payloads []etNewPayload `json:"engineNewPayloads"`
+}
+
+// etNewPayload represents a single engine API new payload call from the fixture.
+type etNewPayload struct {
+ ExecutionPayload engine.ExecutableData
+ VersionedHashes []common.Hash
+ BeaconRoot *common.Hash
+ Requests [][]byte
+
+ Version int // newPayloadVersion
+ FcuVersion int // forkchoiceUpdatedVersion
+ ValidationError string // expected validation error (empty = expect VALID)
+ ErrorCode *int // expected JSON-RPC error code
+}
+
+func (p *etNewPayload) UnmarshalJSON(data []byte) error {
+ var raw struct {
+ Params []json.RawMessage `json:"params"`
+ NewPayloadVersion string `json:"newPayloadVersion"`
+ ForkchoiceUpdatedVersion string `json:"forkchoiceUpdatedVersion"`
+ ValidationError string `json:"validationError,omitempty"`
+ ErrorCode json.RawMessage `json:"errorCode,omitempty"`
+ }
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+ p.ValidationError = raw.ValidationError
+ // errorCode can be a string ("-32602") or int (-32602) in fixtures
+ if len(raw.ErrorCode) > 0 && string(raw.ErrorCode) != "null" {
+ s := string(raw.ErrorCode)
+ // Strip quotes if it's a JSON string
+ if len(s) >= 2 && s[0] == '"' {
+ s = s[1 : len(s)-1]
+ }
+ code, err := strconv.Atoi(s)
+ if err != nil {
+ return fmt.Errorf("invalid errorCode %s: %v", raw.ErrorCode, err)
+ }
+ p.ErrorCode = &code
+ }
+
+ var err error
+ p.Version, err = strconv.Atoi(raw.NewPayloadVersion)
+ if err != nil {
+ return fmt.Errorf("invalid newPayloadVersion: %v", err)
+ }
+ p.FcuVersion, err = strconv.Atoi(raw.ForkchoiceUpdatedVersion)
+ if err != nil {
+ return fmt.Errorf("invalid forkchoiceUpdatedVersion: %v", err)
+ }
+
+ if len(raw.Params) < 1 {
+ return errors.New("params must have at least one element")
+ }
+ // params[0] is always the ExecutableData
+ if err := json.Unmarshal(raw.Params[0], &p.ExecutionPayload); err != nil {
+ return fmt.Errorf("failed to unmarshal ExecutableData: %v", err)
+ }
+ // V3+: params[1] = versionedHashes, params[2] = beaconRoot
+ if len(raw.Params) >= 3 {
+ if err := json.Unmarshal(raw.Params[1], &p.VersionedHashes); err != nil {
+ return fmt.Errorf("failed to unmarshal versionedHashes: %v", err)
+ }
+ var beaconRoot common.Hash
+ if err := json.Unmarshal(raw.Params[2], &beaconRoot); err != nil {
+ return fmt.Errorf("failed to unmarshal beaconRoot: %v", err)
+ }
+ p.BeaconRoot = &beaconRoot
+ }
+ // V4/V5+: params[3] = executionRequests
+ if len(raw.Params) >= 4 {
+ var hexRequests []hexutil.Bytes
+ if err := json.Unmarshal(raw.Params[3], &hexRequests); err != nil {
+ return fmt.Errorf("failed to unmarshal executionRequests: %v", err)
+ }
+ p.Requests = make([][]byte, len(hexRequests))
+ for i, r := range hexRequests {
+ p.Requests[i] = r
+ }
+ }
+ return nil
+}
+
+// Run executes the engine test.
+func (t *EngineTest) Run(scheme string, tracer *tracing.Hooks, postCheck func(error, *core.BlockChain)) (result error) {
+ config, ok := Forks[t.json.Network]
+ if !ok {
+ return UnsupportedForkError{t.json.Network}
+ }
+ // Create genesis spec
+ gspec := t.genesis(config)
+
+ db := rawdb.NewMemoryDatabase()
+ tconf := &triedb.Config{
+ Preimages: true,
+ IsVerkle: gspec.Config.VerkleTime != nil && *gspec.Config.VerkleTime <= gspec.Timestamp,
+ }
+ if scheme == rawdb.PathScheme || tconf.IsVerkle {
+ tconf.PathDB = pathdb.Defaults
+ } else {
+ tconf.HashDB = hashdb.Defaults
+ }
+ if gspec.Config.TerminalTotalDifficulty == nil {
+ gspec.Config.TerminalTotalDifficulty = big.NewInt(stdmath.MaxInt64)
+ }
+ trieDb := triedb.NewDatabase(db, tconf)
+ gblock, err := gspec.Commit(db, trieDb, nil)
+ if err != nil {
+ return err
+ }
+ trieDb.Close()
+
+ if gblock.Hash() != t.json.Genesis.Hash {
+ return fmt.Errorf("genesis block hash doesn't match test: computed=%x, test=%x", gblock.Hash().Bytes()[:6], t.json.Genesis.Hash[:6])
+ }
+ if gblock.Root() != t.json.Genesis.StateRoot {
+ return fmt.Errorf("genesis block state root does not match test: computed=%x, test=%x", gblock.Root().Bytes()[:6], t.json.Genesis.StateRoot[:6])
+ }
+ eng := beacon.New(ethash.NewFaker())
+ options := &core.BlockChainConfig{
+ TrieCleanLimit: 0,
+ StateScheme: scheme,
+ Preimages: true,
+ TxLookupLimit: -1,
+ VmConfig: vm.Config{Tracer: tracer},
+ NoPrefetch: true,
+ }
+ chain, err := core.NewBlockChain(db, gspec, eng, options)
+ if err != nil {
+ return err
+ }
+ defer chain.Stop()
+
+ if postCheck != nil {
+ defer postCheck(result, chain)
+ }
+
+ // Create engine handler and execute payloads
+ // Uses the same core functions as ConsensusAPI (ExecutableDataToBlock,
+ // InsertBlockWithoutSetHead, SetCanonical) — different from blocktest's InsertChain.
+ handler := newEngineHandler(chain)
+
+ // Send initial forkchoiceUpdated to genesis (matching consume engine behavior)
+ genesisHash := chain.Genesis().Hash()
+ initialFcResp := handler.forkchoiceUpdated(engine.ForkchoiceStateV1{
+ HeadBlockHash: genesisHash,
+ SafeBlockHash: genesisHash,
+ FinalizedBlockHash: genesisHash,
+ })
+ if initialFcResp.PayloadStatus.Status != engine.VALID {
+ return fmt.Errorf("initial FCU to genesis returned %s", initialFcResp.PayloadStatus.Status)
+ }
+
+ for i, payload := range t.json.Payloads {
+ status, err := handler.newPayloadVersioned(payload)
+ // Check error code expectation
+ if payload.ErrorCode != nil {
+ var apiErr *engine.EngineAPIError
+ if err == nil || !errors.As(err, &apiErr) {
+ return fmt.Errorf("payload %d: expected error code %d, got err=%v", i, *payload.ErrorCode, err)
+ }
+ if apiErr.ErrorCode() != *payload.ErrorCode {
+ return fmt.Errorf("payload %d: expected error code %d, got %d", i, *payload.ErrorCode, apiErr.ErrorCode())
+ }
+ continue // error code matched, move to next payload
+ }
+ if err != nil {
+ return fmt.Errorf("payload %d: unexpected error: %v", i, err)
+ }
+ // Track last payload status and validation error for result reporting
+ t.LastPayloadStatus = status.Status
+ if status.ValidationError != nil {
+ t.LastValidationError = *status.ValidationError
+ }
+ // Check validation error expectation
+ if payload.ValidationError != "" {
+ if status.Status != engine.INVALID {
+ return fmt.Errorf("payload %d: expected INVALID status for validation error %q, got %s", i, payload.ValidationError, status.Status)
+ }
+ continue // invalid payload as expected, move to next
+ }
+ // Expect valid
+ if status.Status != engine.VALID {
+ errMsg := ""
+ if status.ValidationError != nil {
+ errMsg = *status.ValidationError
+ }
+ return fmt.Errorf("payload %d: expected VALID, got %s (err: %s)", i, status.Status, errMsg)
+ }
+ // Advance chain head via forkchoice update
+ fcResp := handler.forkchoiceUpdated(engine.ForkchoiceStateV1{
+ HeadBlockHash: payload.ExecutionPayload.BlockHash,
+ SafeBlockHash: payload.ExecutionPayload.BlockHash,
+ FinalizedBlockHash: common.Hash{}, // don't set finalized
+ })
+ if fcResp.PayloadStatus.Status != engine.VALID {
+ return fmt.Errorf("payload %d: forkchoiceUpdated returned %s", i, fcResp.PayloadStatus.Status)
+ }
+ }
+
+ // Validate final state
+ cmlast := chain.CurrentBlock().Hash()
+ if common.Hash(t.json.BestBlock) != cmlast {
+ return fmt.Errorf("last block hash validation mismatch: want: %x, have: %x", t.json.BestBlock, cmlast)
+ }
+ if t.json.Post != nil {
+ statedb, err := chain.State()
+ if err != nil {
+ return err
+ }
+ if err := validateEnginePostState(t.json.Post, statedb); err != nil {
+ return fmt.Errorf("post state validation failed: %v", err)
+ }
+ } else if t.json.PostHash != nil {
+ have := chain.CurrentBlock().Root
+ want := common.Hash(*t.json.PostHash)
+ if have != want {
+ return fmt.Errorf("post state root mismatch: want %x, have %x", want, have)
+ }
+ }
+ return nil
+}
+
+func (t *EngineTest) genesis(config *params.ChainConfig) *core.Genesis {
+ return &core.Genesis{
+ Config: config,
+ Nonce: t.json.Genesis.Nonce.Uint64(),
+ Timestamp: t.json.Genesis.Timestamp,
+ ParentHash: t.json.Genesis.ParentHash,
+ ExtraData: t.json.Genesis.ExtraData,
+ GasLimit: t.json.Genesis.GasLimit,
+ GasUsed: t.json.Genesis.GasUsed,
+ Difficulty: t.json.Genesis.Difficulty,
+ Mixhash: t.json.Genesis.MixHash,
+ Coinbase: t.json.Genesis.Coinbase,
+ Alloc: t.json.Pre,
+ BaseFee: t.json.Genesis.BaseFeePerGas,
+ BlobGasUsed: t.json.Genesis.BlobGasUsed,
+ ExcessBlobGas: t.json.Genesis.ExcessBlobGas,
+ }
+}
+
+// validateEnginePostState verifies the post-state accounts match the expected values.
+// Mirrors BlockTest.validatePostState.
+func validateEnginePostState(post types.GenesisAlloc, statedb *state.StateDB) error {
+ for addr, acct := range post {
+ code := statedb.GetCode(addr)
+ balance := statedb.GetBalance(addr).ToBig()
+ nonce := statedb.GetNonce(addr)
+ if !bytes.Equal(code, acct.Code) {
+ return fmt.Errorf("account code mismatch for addr: %s want: %v have: %x", addr, acct.Code, code)
+ }
+ if balance.Cmp(acct.Balance) != 0 {
+ return fmt.Errorf("account balance mismatch for addr: %s, want: %d, have: %d", addr, acct.Balance, balance)
+ }
+ if nonce != acct.Nonce {
+ return fmt.Errorf("account nonce mismatch for addr: %s want: %d have: %d", addr, acct.Nonce, nonce)
+ }
+ for k, v := range acct.Storage {
+ v2 := statedb.GetState(addr, k)
+ if v2 != v {
+ return fmt.Errorf("account storage mismatch for addr: %s, slot: %x, want: %x, have: %x", addr, k, v, v2)
+ }
+ }
+ }
+ return nil
+}
+
+// engineHandler is a lightweight Engine API handler that mirrors the core logic
+// of eth/catalyst.ConsensusAPI but operates directly on a *core.BlockChain
+// without requiring the full eth.Ethereum node stack.
+type engineHandler struct {
+ chain *core.BlockChain
+ invalidBlocksHits map[common.Hash]int
+ invalidTipsets map[common.Hash]*types.Header
+}
+
+func newEngineHandler(chain *core.BlockChain) *engineHandler {
+ return &engineHandler{
+ chain: chain,
+ invalidBlocksHits: make(map[common.Hash]int),
+ invalidTipsets: make(map[common.Hash]*types.Header),
+ }
+}
+
+// newPayloadVersioned dispatches to the appropriate version-specific validation
+// before calling the core newPayload logic. Mirrors NewPayloadV1-V5 in
+// eth/catalyst/api.go.
+func (h *engineHandler) newPayloadVersioned(p etNewPayload) (engine.PayloadStatusV1, error) {
+ params := p.ExecutionPayload
+ switch p.Version {
+ case 1:
+ if params.Withdrawals != nil {
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("withdrawals not supported in V1")
+ }
+ return h.newPayload(params, nil, nil, nil)
+
+ case 2:
+ cancun := h.config().IsCancun(h.config().LondonBlock, params.Timestamp)
+ shanghai := h.config().IsShanghai(h.config().LondonBlock, params.Timestamp)
+ switch {
+ case cancun:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("can't use newPayloadV2 post-cancun")
+ case shanghai && params.Withdrawals == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil withdrawals post-shanghai")
+ case !shanghai && params.Withdrawals != nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("non-nil withdrawals pre-shanghai")
+ case params.ExcessBlobGas != nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("non-nil excessBlobGas pre-cancun")
+ case params.BlobGasUsed != nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("non-nil blobGasUsed pre-cancun")
+ }
+ return h.newPayload(params, nil, nil, nil)
+
+ case 3:
+ switch {
+ case params.Withdrawals == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil withdrawals post-shanghai")
+ case params.ExcessBlobGas == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil excessBlobGas post-cancun")
+ case params.BlobGasUsed == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil blobGasUsed post-cancun")
+ case p.VersionedHashes == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil versionedHashes post-cancun")
+ case p.BeaconRoot == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil beaconRoot post-cancun")
+ case !h.checkFork(params.Timestamp, forks.Cancun, forks.Prague, forks.Osaka, forks.BPO1, forks.BPO2, forks.BPO3, forks.BPO4, forks.BPO5):
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineUnsupportedForkErr("newPayloadV3 must only be called for cancun payloads")
+ }
+ return h.newPayload(params, p.VersionedHashes, p.BeaconRoot, nil)
+
+ case 4:
+ switch {
+ case params.Withdrawals == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil withdrawals post-shanghai")
+ case params.ExcessBlobGas == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil excessBlobGas post-cancun")
+ case params.BlobGasUsed == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil blobGasUsed post-cancun")
+ case p.VersionedHashes == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil versionedHashes post-cancun")
+ case p.BeaconRoot == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil beaconRoot post-cancun")
+ case p.Requests == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil executionRequests post-prague")
+ case !h.checkFork(params.Timestamp, forks.Prague, forks.Osaka, forks.BPO1, forks.BPO2, forks.BPO3, forks.BPO4, forks.BPO5):
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineUnsupportedForkErr("newPayloadV4 must only be called for prague/osaka payloads")
+ }
+ if err := engineValidateRequests(p.Requests); err != nil {
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engine.InvalidParams.With(err)
+ }
+ return h.newPayload(params, p.VersionedHashes, p.BeaconRoot, p.Requests)
+
+ case 5:
+ switch {
+ case params.Withdrawals == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil withdrawals post-shanghai")
+ case params.ExcessBlobGas == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil excessBlobGas post-cancun")
+ case params.BlobGasUsed == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil blobGasUsed post-cancun")
+ case p.VersionedHashes == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil versionedHashes post-cancun")
+ case p.BeaconRoot == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil beaconRoot post-cancun")
+ case p.Requests == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil executionRequests post-prague")
+ case params.SlotNumber == nil:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineParamsErr("nil slotnumber post-amsterdam")
+ case !h.checkFork(params.Timestamp, forks.Amsterdam):
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engineUnsupportedForkErr("newPayloadV5 must only be called for amsterdam payloads")
+ }
+ if err := engineValidateRequests(p.Requests); err != nil {
+ return engine.PayloadStatusV1{Status: engine.INVALID}, engine.InvalidParams.With(err)
+ }
+ return h.newPayload(params, p.VersionedHashes, p.BeaconRoot, p.Requests)
+
+ default:
+ return engine.PayloadStatusV1{Status: engine.INVALID}, fmt.Errorf("unsupported newPayload version: %d", p.Version)
+ }
+}
+
+// newPayload mirrors the core logic of ConsensusAPI.newPayload (api.go:766).
+func (h *engineHandler) newPayload(params engine.ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash, requests [][]byte) (engine.PayloadStatusV1, error) {
+ block, err := engine.ExecutableDataToBlock(params, versionedHashes, beaconRoot, requests)
+ if err != nil {
+ return h.invalid(err, nil), nil
+ }
+ // If we already have the block locally, return VALID immediately
+ if existing := h.chain.GetBlockByHash(params.BlockHash); existing != nil {
+ hash := existing.Hash()
+ return engine.PayloadStatusV1{Status: engine.VALID, LatestValidHash: &hash}, nil
+ }
+ // If this block was rejected previously, keep rejecting it
+ if res := h.checkInvalidAncestor(block.Hash(), block.Hash()); res != nil {
+ return *res, nil
+ }
+ // Check parent exists
+ parent := h.chain.GetBlock(block.ParentHash(), block.NumberU64()-1)
+ if parent == nil {
+ // In a test context with complete fixture data, missing parent is unexpected.
+ // Return SYNCING to match the real engine API behavior.
+ return engine.PayloadStatusV1{Status: engine.SYNCING}, nil
+ }
+ // Check timestamp
+ if block.Time() <= parent.Time() {
+ return h.invalid(errors.New("invalid timestamp"), parent.Header()), nil
+ }
+ // Check parent state exists
+ if !h.chain.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) {
+ return engine.PayloadStatusV1{Status: engine.ACCEPTED}, nil
+ }
+ // Insert block without setting head (same as ConsensusAPI)
+ if _, err := h.chain.InsertBlockWithoutSetHead(context.Background(), block, false); err != nil {
+ h.invalidBlocksHits[block.Hash()] = 1
+ h.invalidTipsets[block.Hash()] = block.Header()
+ return h.invalid(err, parent.Header()), nil
+ }
+ hash := block.Hash()
+ return engine.PayloadStatusV1{Status: engine.VALID, LatestValidHash: &hash}, nil
+}
+
+// forkchoiceUpdated mirrors the core logic of ConsensusAPI.forkchoiceUpdated (api.go:237).
+func (h *engineHandler) forkchoiceUpdated(update engine.ForkchoiceStateV1) engine.ForkChoiceResponse {
+ if update.HeadBlockHash == (common.Hash{}) {
+ return engine.STATUS_INVALID
+ }
+ block := h.chain.GetBlockByHash(update.HeadBlockHash)
+ if block == nil {
+ if res := h.checkInvalidAncestor(update.HeadBlockHash, update.HeadBlockHash); res != nil {
+ return engine.ForkChoiceResponse{PayloadStatus: *res}
+ }
+ return engine.ForkChoiceResponse{PayloadStatus: engine.PayloadStatusV1{Status: engine.SYNCING}}
+ }
+ // Set canonical head if not already the current head
+ if h.chain.CurrentBlock().Hash() != update.HeadBlockHash {
+ if latestValid, err := h.chain.SetCanonical(block); err != nil {
+ return engine.ForkChoiceResponse{
+ PayloadStatus: engine.PayloadStatusV1{Status: engine.INVALID, LatestValidHash: &latestValid},
+ }
+ }
+ }
+ // Set finalized block if specified
+ if update.FinalizedBlockHash != (common.Hash{}) {
+ finalBlock := h.chain.GetBlockByHash(update.FinalizedBlockHash)
+ if finalBlock != nil {
+ h.chain.SetFinalized(finalBlock.Header())
+ }
+ }
+ // Set safe block if specified
+ if update.SafeBlockHash != (common.Hash{}) {
+ safeBlock := h.chain.GetBlockByHash(update.SafeBlockHash)
+ if safeBlock != nil {
+ h.chain.SetSafe(safeBlock.Header())
+ }
+ }
+ return engine.ForkChoiceResponse{
+ PayloadStatus: engine.PayloadStatusV1{
+ Status: engine.VALID,
+ LatestValidHash: &update.HeadBlockHash,
+ },
+ }
+}
+
+// checkInvalidAncestor mirrors ConsensusAPI.checkInvalidAncestor (api.go:952).
+func (h *engineHandler) checkInvalidAncestor(check common.Hash, head common.Hash) *engine.PayloadStatusV1 {
+ invalid, ok := h.invalidTipsets[check]
+ if !ok {
+ return nil
+ }
+ badHash := invalid.Hash()
+ h.invalidBlocksHits[badHash]++
+ if h.invalidBlocksHits[badHash] >= 128 {
+ delete(h.invalidBlocksHits, badHash)
+ for descendant, badHeader := range h.invalidTipsets {
+ if badHeader.Hash() == badHash {
+ delete(h.invalidTipsets, descendant)
+ }
+ }
+ return nil
+ }
+ if check != head {
+ if len(h.invalidTipsets) >= 512 {
+ for key := range h.invalidTipsets {
+ delete(h.invalidTipsets, key)
+ break
+ }
+ }
+ h.invalidTipsets[head] = invalid
+ }
+ lastValid := &invalid.ParentHash
+ if header := h.chain.GetHeader(invalid.ParentHash, invalid.Number.Uint64()-1); header != nil && header.Difficulty.Sign() != 0 {
+ lastValid = &common.Hash{}
+ }
+ failure := "links to previously rejected block"
+ return &engine.PayloadStatusV1{
+ Status: engine.INVALID,
+ LatestValidHash: lastValid,
+ ValidationError: &failure,
+ }
+}
+
+// invalid mirrors ConsensusAPI.invalid (api.go:1002).
+func (h *engineHandler) invalid(err error, latestValid *types.Header) engine.PayloadStatusV1 {
+ var currentHash *common.Hash
+ if latestValid != nil {
+ if latestValid.Difficulty.BitLen() != 0 {
+ currentHash = &common.Hash{}
+ } else {
+ hash := latestValid.Hash()
+ currentHash = &hash
+ }
+ }
+ errorMsg := err.Error()
+ return engine.PayloadStatusV1{
+ Status: engine.INVALID,
+ LatestValidHash: currentHash,
+ ValidationError: &errorMsg,
+ }
+}
+
+func (h *engineHandler) config() *params.ChainConfig {
+ return h.chain.Config()
+}
+
+func (h *engineHandler) checkFork(timestamp uint64, allowedForks ...forks.Fork) bool {
+ latest := h.config().LatestFork(timestamp)
+ for _, fork := range allowedForks {
+ if latest == fork {
+ return true
+ }
+ }
+ return false
+}
+
+// engineParamsErr creates an InvalidParams Engine API error.
+func engineParamsErr(msg string) error {
+ return engine.InvalidParams.With(errors.New(msg))
+}
+
+// engineUnsupportedForkErr creates an UnsupportedFork Engine API error.
+func engineUnsupportedForkErr(msg string) error {
+ return engine.UnsupportedFork.With(errors.New(msg))
+}
+
+// engineValidateRequests checks that requests are ordered by type and not empty.
+// Mirrors validateRequests in eth/catalyst/api.go.
+func engineValidateRequests(requests [][]byte) error {
+ for i, req := range requests {
+ if len(req) < 2 {
+ return fmt.Errorf("empty request: %v", req)
+ }
+ if i > 0 && req[0] <= requests[i-1][0] {
+ return fmt.Errorf("invalid request order: %v", req)
+ }
+ }
+ return nil
+}
diff --git a/tests/state_test_util.go b/tests/state_test_util.go
index 1dd1bf6a047..04615117ef0 100644
--- a/tests/state_test_util.go
+++ b/tests/state_test_util.go
@@ -51,7 +51,8 @@ import (
// StateTest checks transaction processing without block context.
// See https://github.com/ethereum/EIPs/issues/176 for the test format specification.
type StateTest struct {
- json stJSON
+ json stJSON
+ LastTxError string // actual tx error, for result reporting
}
// StateSubtest selects a specific configuration of a General State Test.
@@ -211,7 +212,7 @@ func (t *StateTest) checkError(subtest StateSubtest, err error) error {
return fmt.Errorf("unexpected error: %w", err)
}
if err != nil && expectedError != "" {
- // Ignore expected errors (TODO MariusVanDerWijden check error string)
+ t.LastTxError = err.Error()
return nil
}
return nil