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