diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 14f12b53..a38208c1 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -10,6 +10,7 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
permissions:
contents: read
@@ -18,12 +19,13 @@ jobs:
# what gates PRs.
go:
runs-on: ubuntu-latest
- needs: [go_test, go_generate, go_tidy, require_fuzz_corpus]
+ needs: [go_test, go_spec_tests, go_generate, go_tidy, require_fuzz_corpus]
# TODO(arr4n) investigate why setup-go wasn't properly caching fuzz corpora
# and then reinstate a go_fuzz job that extends them.
steps:
- run: echo "Dependencies successful"
+ # TODO(cey): we should only run -short tests in PRs and then run the full tests in main.
go_test:
runs-on: ubuntu-latest
steps:
@@ -34,6 +36,28 @@ jobs:
go-version-file: "go.mod"
- run: go test ./...
+ go_spec_tests:
+ runs-on: ubuntu-latest
+ env:
+ EXECUTION_SPEC_TESTS_VERSION: v2.1.0
+ EXECUTION_SPEC_TESTS_FILE: fixtures_develop.tar.gz
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Download spec tests
+ run: curl -o "${EXECUTION_SPEC_TESTS_FILE}" -L "https://github.com/ethereum/execution-spec-tests/releases/download/${EXECUTION_SPEC_TESTS_VERSION}/${EXECUTION_SPEC_TESTS_FILE}"
+ - name: Verify spec tests
+ run: |
+ sha256sum --ignore-missing --check checksums.txt | grep "${EXECUTION_SPEC_TESTS_FILE}: OK"
+ - name: Extract spec tests
+ run: mkdir -p saexec/ethtests/spec-tests && tar -xz -f "${EXECUTION_SPEC_TESTS_FILE}" -C saexec/ethtests/spec-tests
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: "go.mod"
+ - run: go test -short ./saexec/ethtests/...
+
go_generate:
env:
EXCLUDE_REGEX: "ava-labs/libevm/(accounts/usbwallet/trezor)$"
diff --git a/.gitignore b/.gitignore
index 722d5e71..f0509018 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
.vscode
+saexec/ethtests/spec-tests
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..268e824b
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "saexec/ethtests/testdata"]
+ path = saexec/ethtests/testdata
+ url = https://github.com/ethereum/tests
+ shallow = true
diff --git a/.golangci.yml b/.golangci.yml
index 480f6ae1..c93a99b6 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -103,3 +103,9 @@ issues:
- EXC0013
- EXC0014
- EXC0015
+ exclude-rules:
+ # These files include an upstream (go-ethereum) notice block that we want to
+ # keep verbatim; allow them to deviate from the standard short header.
+ - path: ^saexec/ethtests/(block_test|block_test_util|init|init_test)\.go$
+ linters:
+ - goheader
diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go
index fb346159..67919599 100644
--- a/blocks/blockstest/blocks.go
+++ b/blocks/blockstest/blocks.go
@@ -11,6 +11,7 @@ import (
"math"
"math/big"
"slices"
+ "sort"
"sync/atomic"
"testing"
"time"
@@ -109,19 +110,17 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al
tb.Helper()
conf := &genesisConfig{
gasTarget: math.MaxUint64,
+ genesisSpec: &core.Genesis{
+ Config: config,
+ Alloc: alloc,
+ },
}
options.ApplyTo(conf, opts...)
-
- gen := &core.Genesis{
- Config: config,
- Timestamp: conf.timestamp,
- Alloc: alloc,
- }
+ gen := conf.genesisSpec
tdb := state.NewDatabaseWithConfig(db, conf.tdbConfig).TrieDB()
- _, hash, err := core.SetupGenesisBlock(db, tdb, gen)
+ _, _, err := core.SetupGenesisBlock(db, tdb, gen)
require.NoError(tb, err, "core.SetupGenesisBlock()")
- require.NoErrorf(tb, tdb.Commit(hash, true), "%T.Commit(core.SetupGenesisBlock(...))", tdb)
b := NewBlock(tb, gen.ToBlock(), nil, nil)
require.NoErrorf(tb, b.MarkExecuted(
@@ -138,10 +137,10 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al
}
type genesisConfig struct {
- tdbConfig *triedb.Config
- timestamp uint64
- gasTarget gas.Gas
- gasExcess gas.Gas
+ tdbConfig *triedb.Config
+ gasTarget gas.Gas
+ gasExcess gas.Gas
+ genesisSpec *core.Genesis
}
// A GenesisOption configures [NewGenesis].
@@ -154,10 +153,17 @@ func WithTrieDBConfig(tc *triedb.Config) GenesisOption {
})
}
+// WithGenesisSpec overrides the genesis spec used by [NewGenesis].
+func WithGenesisSpec(gen *core.Genesis) GenesisOption {
+ return options.Func[genesisConfig](func(gc *genesisConfig) {
+ gc.genesisSpec = gen
+ })
+}
+
// WithTimestamp overrides the timestamp used by [NewGenesis].
func WithTimestamp(timestamp uint64) GenesisOption {
return options.Func[genesisConfig](func(gc *genesisConfig) {
- gc.timestamp = timestamp
+ gc.genesisSpec.Timestamp = timestamp
})
}
@@ -175,6 +181,55 @@ func WithGasExcess(excess gas.Gas) GenesisOption {
})
}
+// WithFakeBaseFee creates a new block wrapping the given eth block with a fake
+// parent that has its gastime adjusted to produce the desired base fee.
+// Upon execution of the resulting block, the fake parent will have its base fee
+// set to the desired base fee, thus overriding the base fee mechanism.
+// This is useful for tests that need to override the base fee mechanism.
+//
+// The fake parent is marked as executed with the gastime configured to yield
+// the specified base fee. The build time is set to match the block time to
+// prevent fast-forwarding the excess during execution.
+func WithFakeBaseFee(tb testing.TB, db ethdb.Database, parent *blocks.Block, eth *types.Block, baseFee *big.Int) *blocks.Block {
+ tb.Helper()
+
+ target := parent.ExecutedByGasTime().Target()
+ desiredExcessGas := desiredExcess(gas.Price(baseFee.Uint64()), target)
+
+ var grandParent *blocks.Block
+ if parent.NumberU64() != 0 {
+ grandParent = parent.ParentBlock()
+ }
+
+ fakeParent := NewBlock(tb, parent.EthBlock(), grandParent, nil)
+ // Set the build time to the block time so that we do not fast forward
+ // the excess to the block time during execution.
+ require.NoError(tb, fakeParent.MarkExecuted(
+ db,
+ gastime.New(eth.Time(), target, desiredExcessGas),
+ time.Time{},
+ baseFee,
+ nil,
+ parent.PostExecutionStateRoot(),
+ new(atomic.Pointer[blocks.Block]),
+ ))
+ require.Equal(tb, baseFee.Uint64(), fakeParent.ExecutedByGasTime().BaseFee().Uint64())
+
+ return NewBlock(tb, eth, fakeParent, nil)
+}
+
+// desiredExcess calculates the excess gas needed to produce the desired price.
+func desiredExcess(desiredPrice gas.Price, target gas.Gas) gas.Gas {
+ // This could be solved directly by calculating D * ln(desiredPrice / P)
+ // using floating point math. However, it introduces inaccuracies. So, we
+ // use a binary search to find the closest integer solution.
+ return gas.Gas(sort.Search(math.MaxInt32, func(excessGuess int) bool { //nolint:gosec // Known to not overflow
+ tm := gastime.New(0, target, gas.Gas(excessGuess)) //nolint:gosec // Known to not overflow
+ price := tm.Price()
+ return price >= desiredPrice
+ }))
+}
+
// SetUninformativeWorstCaseBounds calls [blocks.Block.SetWorstCaseBounds] with
// a base fee of 2^256-1 and tx-sender balances of zero. These are guaranteed to
// pass the checks and never result in error logs, and MUST NOT be used in full
diff --git a/blocks/blockstest/chain.go b/blocks/blockstest/chain.go
index 3301b380..14a96986 100644
--- a/blocks/blockstest/chain.go
+++ b/blocks/blockstest/chain.go
@@ -34,9 +34,10 @@ type ChainBuilder struct {
func NewChainBuilder(config *params.ChainConfig, genesis *blocks.Block, defaultOpts ...ChainOption) *ChainBuilder {
c := &ChainBuilder{
config: config,
- chain: []*blocks.Block{genesis},
+ chain: []*blocks.Block{},
}
c.SetDefaultOptions(defaultOpts...)
+ c.insert(genesis)
return c
}
@@ -81,15 +82,25 @@ func (cb *ChainBuilder) NewBlock(tb testing.TB, txs []*types.Transaction, opts .
last := cb.Last()
eth := NewEthBlock(last.EthBlock(), txs, allOpts.eth...)
b := NewBlock(tb, eth, last, nil, allOpts.sae...) // TODO(arr4n) support last-settled blocks
- signer := types.MakeSigner(cb.config, b.Number(), b.BuildTime())
- SetUninformativeWorstCaseBounds(tb, signer, b)
-
- cb.chain = append(cb.chain, b)
- cb.blocksByHash.Store(b.Hash(), b)
+ cb.Insert(tb, b)
return b
}
+// Insert sets the block's invariants and adds it to the chain.
+func (cb *ChainBuilder) Insert(tb testing.TB, block *blocks.Block) {
+ tb.Helper()
+ signer := types.MakeSigner(cb.config, block.Number(), block.BuildTime())
+ SetUninformativeWorstCaseBounds(tb, signer, block)
+ cb.insert(block)
+}
+
+// insert adds a block to the chain.
+func (cb *ChainBuilder) insert(block *blocks.Block) {
+ cb.chain = append(cb.chain, block)
+ cb.blocksByHash.Store(block.Hash(), block)
+}
+
// Last returns the last block to be built by the builder, which MAY be the
// genesis block passed to the constructor.
func (cb *ChainBuilder) Last() *blocks.Block {
@@ -120,3 +131,23 @@ func (cb *ChainBuilder) GetBlock(h common.Hash, num uint64) (*blocks.Block, bool
}
return b, true
}
+
+// BlockByNumber returns the block at the given height, and a flag indicating if it was found.
+// If the height is greater than the number of blocks in the chain, it returns an empty hash and false.
+func (cb *ChainBuilder) BlockByNumber(num uint64) (*blocks.Block, bool) {
+ if num >= uint64(len(cb.chain)) {
+ return nil, false
+ }
+ block := cb.chain[num]
+ return block, true
+}
+
+// BlockByHash returns the block with the given hash, and a flag indicating if it was found.
+func (cb *ChainBuilder) BlockByHash(h common.Hash) (*blocks.Block, bool) {
+ ifc, _ := cb.blocksByHash.Load(h)
+ b, ok := ifc.(*blocks.Block)
+ if !ok {
+ return nil, false
+ }
+ return b, true
+}
diff --git a/checksums.txt b/checksums.txt
new file mode 100644
index 00000000..35b9ffd1
--- /dev/null
+++ b/checksums.txt
@@ -0,0 +1,6 @@
+# This file contains sha256 checksums of optional build dependencies.
+
+# version:spec-tests 2.1.0
+# https://github.com/ethereum/execution-spec-tests/releases
+# https://github.com/ethereum/execution-spec-tests/releases/download/v2.1.0/
+ca89c76851b0900bfcc3cbb9a26cbece1f3d7c64a3bed38723e914713290df6c fixtures_develop.tar.gz
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 2604f7d1..a921f77c 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,10 @@ module github.com/ava-labs/strevm
go 1.24.11
-tool github.com/go-task/task/v3/cmd/task
+tool (
+ github.com/fjl/gencodec
+ github.com/go-task/task/v3/cmd/task
+)
require (
github.com/ava-labs/avalanchego v1.14.2-0.20260112194842-874bc6c9d305
@@ -46,7 +49,9 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/ethereum/c-kzg-4844 v1.0.0 // indirect
github.com/fatih/color v1.18.0 // indirect
+ github.com/fjl/gencodec v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/garslo/gogen v0.0.0-20170306192744-1d203ffc1f61 // indirect
github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 // indirect
github.com/gballet/go-verkle v0.1.1-0.20231031103413-a67434b50f46 // indirect
github.com/getsentry/sentry-go v0.35.0 // indirect
@@ -112,8 +117,10 @@ require (
github.com/zeebo/xxh3 v1.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
+ golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/time v0.12.0 // indirect
+ golang.org/x/tools v0.38.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
mvdan.cc/sh/moreinterp v0.0.0-20251109230715-65adef8e2c5b // indirect
mvdan.cc/sh/v3 v3.12.0 // indirect
diff --git a/go.sum b/go.sum
index 233b627a..2e9a962d 100644
--- a/go.sum
+++ b/go.sum
@@ -141,11 +141,15 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/fjl/gencodec v0.1.1 h1:DhQY29Q6JLXB/GgMqE86NbOEuvckiYcJCbXFu02toms=
+github.com/fjl/gencodec v0.1.1/go.mod h1:chDHL3wKXuBgauP8x3XNZkl5EIAR5SoCTmmmDTZRzmw=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/garslo/gogen v0.0.0-20170306192744-1d203ffc1f61 h1:IZqZOB2fydHte3kUgxrzK5E1fW7RQGeDwE8F/ZZnUYc=
+github.com/garslo/gogen v0.0.0-20170306192744-1d203ffc1f61/go.mod h1:Q0X6pkwTILDlzrGEckF6HKjXe48EgsY/l7K7vhY4MW8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 h1:f6D9Hr8xV8uYKlyuj8XIruxlh9WjVjdh1gIicAS7ays=
github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww=
@@ -665,6 +669,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/sae/vm.go b/sae/vm.go
index 331e5ed4..ae4fb5c5 100644
--- a/sae/vm.go
+++ b/sae/vm.go
@@ -21,6 +21,7 @@ import (
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core"
"github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/state/snapshot"
"github.com/ava-labs/libevm/core/txpool"
"github.com/ava-labs/libevm/core/txpool/legacypool"
"github.com/ava-labs/libevm/core/types"
@@ -119,6 +120,7 @@ func (vm *VM) Init(
if err := snowCtx.Metrics.Register("sae", vm.metrics); err != nil {
return err
}
+ snapshotConfig := snapshot.Config{CacheSize: 128, AsyncBuild: true}
{ // ========== Executor ==========
exec, err := saexec.New(
@@ -127,6 +129,7 @@ func (vm *VM) Init(
chainConfig,
db,
triedbConfig,
+ snapshotConfig,
hooks,
snowCtx.Log,
)
diff --git a/saetest/logging.go b/saetest/logging.go
index 75d29e14..1fdc987e 100644
--- a/saetest/logging.go
+++ b/saetest/logging.go
@@ -108,14 +108,14 @@ func (l *LogRecorder) AtLeast(lvl logging.Level) []*LogRecord {
// NewTBLogger constructs a logger that propagates logs to [testing.TB]. WARNING
// and ERROR logs are sent to [testing.TB.Errorf] while FATAL is sent to
// [testing.TB.Fatalf]. All other logs are sent to [testing.TB.Logf]. Although
-// the level can be configured, it is silently capped at [logging.Warn].
+// the level can be configured, it is silently capped at [logging.Error].
//
//nolint:thelper // The outputs include the logging site while the TB site is most useful if here
func NewTBLogger(tb testing.TB, level logging.Level) *TBLogger {
l := &TBLogger{tb: tb}
l.logger = &logger{
handler: l, // TODO(arr4n) remove the recursion here and in [LogRecorder]
- level: min(level, logging.Warn),
+ level: min(level, logging.Error),
}
return l
}
diff --git a/saexec/ethtests/block_test.go b/saexec/ethtests/block_test.go
new file mode 100644
index 00000000..19bac904
--- /dev/null
+++ b/saexec/ethtests/block_test.go
@@ -0,0 +1,144 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+//
+// This file is a derived work, based on the go-ethereum library whose original
+// notices appear below.
+//
+// It is distributed under a license compatible with the licensing terms of the
+// original code from which it is derived.
+//
+// Much love to the original authors for their work.
+// **********
+// Copyright 2015 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 ethtests
+
+import (
+ "regexp"
+ "testing"
+
+ "github.com/ava-labs/libevm/common"
+ "github.com/ava-labs/libevm/core/rawdb"
+)
+
+func TestBlockchain(t *testing.T) {
+ bt := new(testMatcher)
+ // General state tests are 'exported' as blockchain tests, but we can run them natively.
+ // For speedier CI-runs, the line below can be uncommented, so those are skipped.
+ // For now, in hardfork-times (Berlin), we run the tests both as StateTests and
+ // as blockchain tests, since the latter also covers things like receipt root
+ bt.slow(`^GeneralStateTests/`)
+
+ // Skip random failures due to selfish mining test
+ bt.skipLoad(`.*bcForgedTest/bcForkUncle\.json`)
+
+ // Slow tests
+ bt.slow(`.*bcExploitTest/DelegateCallSpam.json`)
+ bt.slow(`.*bcExploitTest/ShanghaiLove.json`)
+ bt.slow(`.*bcExploitTest/SuicideIssue.json`)
+ bt.slow(`.*/bcForkStressTest/`)
+ bt.slow(`.*/bcGasPricerTest/RPC_API_Test.json`)
+ bt.slow(`.*/bcWalletTest/`)
+
+ // Very slow test
+ bt.skipLoad(`.*/stTimeConsuming/.*`)
+ // test takes a lot for time and goes easily OOM because of sha3 calculation on a huge range,
+ // using 4.6 TGas
+ bt.skipLoad(`.*randomStatetest94.json.*`)
+
+ // TODO(cey): We cannot run invalid blocks, since we don't currently have pre-insert checks.
+ bt.skipLoad(`^InvalidBlocks/`)
+
+ // TODO(cey): We cannot run Fork tests or side-chain tests as we don't support reorg/sidechain management?
+ bt.skipLoad(`.*/bcForkStressTest/`)
+ bt.skipLoad(`.*/bcTotalDifficultyTest/`)
+ bt.skipLoad(`.*/bcMultiChainTest/`)
+ bt.skipLoad(`.*/bcGasPricerTest/RPC_API_Test.json`)
+ bt.skipLoad(`.*/bcFrontierToHomestead/blockChainFrontierWithLargerTDvsHomesteadBlockchain.json`)
+ bt.skipLoad(`.*/bcFrontierToHomestead/blockChainFrontierWithLargerTDvsHomesteadBlockchain2.json`)
+ var skippedTestRegexp []string
+ // TODO(cey): We cannot run Pre-check tests
+ // skip code maxsize tests
+ skippedTestRegexp = append(skippedTestRegexp, `.*/contract_creating_tx.json/002-fork=Shanghai-over_limit_zeros-over_limit_zeros`)
+ skippedTestRegexp = append(skippedTestRegexp, `.*/contract_creating_tx.json/003-fork=Shanghai-over_limit_ones-over_limit_ones`)
+ skippedTestRegexp = append(skippedTestRegexp, `.*/contract_creating_tx.json/006-fork=Cancun-over_limit_zeros-over_limit_zeros`)
+ skippedTestRegexp = append(skippedTestRegexp, `.*/contract_creating_tx.json/007-fork=Cancun-over_limit_ones-over_limit_ones`)
+ // skip intrinsic gas tests
+ skippedTestRegexp = append(skippedTestRegexp, `.*/gas_usage.json/.*too_little_intrinsic_gas.*`)
+ bt.skipLoad(`.*/bcFrontierToHomestead/HomesteadOverrideFrontier.json`)
+ bt.skipLoad(`.*/bcFrontierToHomestead/ContractCreationFailsOnHomestead.json`)
+ // skip insufficient funds tests
+ skippedTestRegexp = append(skippedTestRegexp, `.*/use_value_in_tx.json/000-fork=Shanghai-tx_in_withdrawals_block`)
+ skippedTestRegexp = append(skippedTestRegexp, `.*/use_value_in_tx.json/002-fork=Cancun-tx_in_withdrawals_block`)
+ // Skip tx type check
+ bt.skipLoad(`.*/bcBerlinToLondon/initialVal.json`)
+
+ bt.walk(t, blockTestDir, func(t *testing.T, name string, test *BlockTest) {
+ t.Helper()
+ for _, skippedTestName := range skippedTestRegexp {
+ if regexp.MustCompile(skippedTestName).MatchString(name) {
+ t.Skipf("test %s skipped", name)
+ }
+ }
+ execBlockTest(t, bt, test)
+ })
+ // There is also a LegacyTests folder, containing blockchain tests generated
+ // prior to Istanbul. However, they are all derived from GeneralStateTests,
+ // which run natively, so there's no reason to run them here.
+ bt.walk(t, legacyBlockTestDir, func(t *testing.T, name string, test *BlockTest) {
+ t.Helper()
+ for _, skippedTestName := range skippedTestRegexp {
+ if regexp.MustCompile(skippedTestName).MatchString(name) {
+ t.Skipf("test %s skipped", name)
+ }
+ }
+ execBlockTest(t, bt, test)
+ })
+}
+
+// TestExecutionSpecBlocktests runs the test fixtures from execution-spec-tests.
+func TestExecutionSpecBlocktests(t *testing.T) {
+ if !common.FileExist(executionSpecBlockchainTestDir) {
+ t.Skipf("directory %s does not exist", executionSpecBlockchainTestDir)
+ }
+ bt := new(testMatcher)
+
+ bt.walk(t, executionSpecBlockchainTestDir, func(t *testing.T, name string, test *BlockTest) {
+ t.Helper()
+ execBlockTest(t, bt, test)
+ })
+}
+
+func execBlockTest(t *testing.T, bt *testMatcher, test *BlockTest) {
+ t.Helper()
+ if err := bt.checkFailure(t, test.Run(t, false, rawdb.HashScheme, nil, nil)); err != nil {
+ t.Errorf("test in hash mode without snapshotter failed: %v", err)
+ return
+ }
+ if err := bt.checkFailure(t, test.Run(t, true, rawdb.HashScheme, nil, nil)); err != nil {
+ t.Errorf("test in hash mode with snapshotter failed: %v", err)
+ return
+ }
+ if err := bt.checkFailure(t, test.Run(t, false, rawdb.PathScheme, nil, nil)); err != nil {
+ t.Errorf("test in path mode without snapshotter failed: %v", err)
+ return
+ }
+ if err := bt.checkFailure(t, test.Run(t, true, rawdb.PathScheme, nil, nil)); err != nil {
+ t.Errorf("test in path mode with snapshotter failed: %v", err)
+ return
+ }
+}
diff --git a/saexec/ethtests/block_test_util.go b/saexec/ethtests/block_test_util.go
new file mode 100644
index 00000000..535ef63d
--- /dev/null
+++ b/saexec/ethtests/block_test_util.go
@@ -0,0 +1,422 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+//
+// This file is a derived work, based on the go-ethereum library whose original
+// notices appear below.
+//
+// It is distributed under a license compatible with the licensing terms of the
+// original code from which it is derived.
+//
+// Much love to the original authors for their work.
+// **********
+// Copyright 2015 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 ethtests implements execution of Ethereum JSON tests.
+package ethtests
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/ava-labs/libevm/common"
+ "github.com/ava-labs/libevm/common/hexutil"
+ "github.com/ava-labs/libevm/common/math"
+ "github.com/ava-labs/libevm/consensus/beacon"
+ "github.com/ava-labs/libevm/consensus/ethash"
+ "github.com/ava-labs/libevm/core"
+ "github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/state"
+ "github.com/ava-labs/libevm/core/state/snapshot"
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/core/vm"
+ "github.com/ava-labs/libevm/params"
+ "github.com/ava-labs/libevm/rlp"
+ "github.com/ava-labs/libevm/triedb"
+ "github.com/ava-labs/libevm/triedb/hashdb"
+ "github.com/ava-labs/libevm/triedb/pathdb"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ava-labs/strevm/blocks"
+ "github.com/ava-labs/strevm/blocks/blockstest"
+)
+
+// A BlockTest checks handling of entire blocks.
+type BlockTest struct {
+ json btJSON
+}
+
+// UnmarshalJSON implements json.Unmarshaler interface.
+func (t *BlockTest) UnmarshalJSON(in []byte) error {
+ return json.Unmarshal(in, &t.json)
+}
+
+type btJSON struct {
+ Blocks []btBlock `json:"blocks"`
+ Genesis btHeader `json:"genesisBlockHeader"`
+ Pre types.GenesisAlloc `json:"pre"`
+ Post types.GenesisAlloc `json:"postState"`
+ BestBlock common.UnprefixedHash `json:"lastblockhash"`
+ Network string `json:"network"`
+ SealEngine string `json:"sealEngine"`
+}
+
+type btBlock struct {
+ BlockHeader *btHeader
+ ExpectException string
+ Rlp string
+ UncleHeaders []*btHeader
+}
+
+//go:generate go run github.com/fjl/gencodec -type btHeader -field-override btHeaderMarshaling -out gen_btheader.go
+
+type btHeader struct {
+ Bloom types.Bloom
+ Coinbase common.Address
+ MixHash common.Hash
+ Nonce types.BlockNonce
+ Number *big.Int
+ Hash common.Hash
+ ParentHash common.Hash
+ ReceiptTrie common.Hash
+ StateRoot common.Hash
+ TransactionsTrie common.Hash
+ UncleHash common.Hash
+ ExtraData []byte
+ Difficulty *big.Int
+ GasLimit uint64
+ GasUsed uint64
+ Timestamp uint64
+ BaseFeePerGas *big.Int
+ WithdrawalsRoot *common.Hash
+ BlobGasUsed *uint64
+ ExcessBlobGas *uint64
+ ParentBeaconBlockRoot *common.Hash
+}
+
+type btHeaderMarshaling struct {
+ ExtraData hexutil.Bytes
+ Number *math.HexOrDecimal256
+ Difficulty *math.HexOrDecimal256
+ GasLimit math.HexOrDecimal64
+ GasUsed math.HexOrDecimal64
+ Timestamp math.HexOrDecimal64
+ BaseFeePerGas *math.HexOrDecimal256
+ BlobGasUsed *math.HexOrDecimal64
+ ExcessBlobGas *math.HexOrDecimal64
+}
+
+// Run runs the block test.
+//
+// snapshotter: whether to use snapshots.
+// scheme: the scheme to use for the trie database. (rawdb.PathScheme or rawdb.HashScheme)
+// tracer: the tracer to use for the execution.
+// postCheck: the post-check function to run after the execution.
+//
+// Returns the result of the execution.
+func (t *BlockTest) Run(tb testing.TB, snapshotter bool, scheme string, tracer vm.EVMLogger, postCheck func(error, *SUT)) (result error) {
+ tb.Helper()
+ config, ok := Forks[t.json.Network]
+ if !ok {
+ return UnsupportedForkError{t.json.Network}
+ }
+ opts := []sutOption{withChainConfig(config)}
+
+ // Configure trie database configuration
+ tconf := &triedb.Config{
+ Preimages: true,
+ }
+ if scheme == rawdb.PathScheme {
+ tconf.PathDB = pathdb.Defaults
+ } else {
+ tconf.HashDB = hashdb.Defaults
+ }
+ // Configure snapshot configuration
+ opts = append(opts, withTrieDBConfig(tconf))
+ if snapshotter {
+ snapshotConfig := snapshot.Config{
+ CacheSize: 1,
+ AsyncBuild: false,
+ }
+ opts = append(opts, withSnapshotConfig(&snapshotConfig))
+ }
+ // Commit genesis state
+ gspec := t.genesis(config)
+ opts = append(opts, withGenesisSpec(gspec))
+
+ // Wrap the original engine within the beacon-engine
+ engine := beacon.New(ethash.NewFaker())
+ ctx, sut := newSUT(tb, engine, opts...)
+ gblock := sut.LastExecuted()
+ require.Equal(tb, gblock.Hash(), t.json.Genesis.Hash)
+ require.Equal(tb, gblock.PostExecutionStateRoot(), t.json.Genesis.StateRoot)
+ require.Equal(tb, gblock.Header().Root, t.json.Genesis.StateRoot)
+
+ validBlocks, err := t.insertBlocks(tb, ctx, &sut)
+ if err != nil {
+ return err
+ }
+ // Import succeeded: regardless of whether the _test_ succeeds or not, schedule
+ // the post-check to run
+ if postCheck != nil {
+ defer postCheck(result, &sut)
+ }
+ last := sut.Chain.Last()
+ lastHash := last.Hash()
+ if common.Hash(t.json.BestBlock) != lastHash {
+ return fmt.Errorf("last block hash validation mismatch: want: %x, have: %x", t.json.BestBlock, lastHash)
+ }
+
+ sdb, err := state.New(last.PostExecutionStateRoot(), sut.StateCache(), nil)
+ require.NoErrorf(tb, err, "state.New(%T.PostExecutionStateRoot(), %T.StateCache(), nil)", last, sut)
+ if err = t.validatePostState(sdb); err != nil {
+ return fmt.Errorf("post state validation failed: %v", err)
+ }
+ // Cross-check the snapshot-to-hash against the trie hash
+ if snapshotter {
+ snaps := sut.Snapshots()
+ require.NoErrorf(tb, err, "snapshot.New(..., %T.PostExecutionStateRoot())", sut)
+ if err := snaps.Verify(last.PostExecutionStateRoot()); err != nil {
+ return err
+ }
+ }
+ return t.validateImportedHeaders(sut.Chain, validBlocks)
+}
+
+func (t *BlockTest) 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,
+ }
+}
+
+/*
+See https://github.com/ethereum/tests/wiki/Blockchain-Tests-II
+
+ Whether a block is valid or not is a bit subtle, it's defined by presence of
+ blockHeader, transactions and uncleHeaders fields. If they are missing, the block is
+ invalid and we must verify that we do not accept it.
+
+ Since some tests mix valid and invalid blocks we need to check this for every block.
+
+ If a block is invalid it does not necessarily fail the test, if it's invalidness is
+ expected we are expected to ignore it and continue processing and then validate the
+ post state.
+*/
+func (t *BlockTest) insertBlocks(tb testing.TB, ctx context.Context, sut *SUT) ([]btBlock, error) {
+ tb.Helper()
+ validBlocks := make([]btBlock, 0)
+ blocks := make([]*types.Block, 0)
+ // insert the test blocks, which will execute all transactions
+ for bi, b := range t.json.Blocks {
+ cb, err := b.decode()
+ if err != nil {
+ if b.BlockHeader == nil {
+ tb.Log("Block decoding failed", "index", bi, "err", err)
+ continue // OK - block is supposed to be invalid, continue with next block
+ } else {
+ return nil, fmt.Errorf("block RLP decoding failed when expected to succeed: %v", err)
+ }
+ }
+
+ if b.BlockHeader == nil {
+ // TODO(cey): We don't have any insertion rules at the moment, so skip them.
+ tb.Logf("skipping checking the invalid test block insertion (index %d) with expected exception: %v, because we don't have any insertion rules", bi, b.ExpectException)
+ tb.SkipNow()
+ if data, err := json.MarshalIndent(cb.Header(), "", " "); err == nil {
+ 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",
+ bi, b.ExpectException)
+ }
+
+ // validate RLP decoding by checking all values against test file JSON
+ if err = validateHeader(b.BlockHeader, cb.Header()); err != nil {
+ return nil, fmt.Errorf("deserialised block header validation failed: %v", err)
+ }
+ validBlocks = append(validBlocks, b)
+ blocks = append(blocks, cb)
+ }
+
+ // Insert the blocks into the chain
+ insertWithHeaderBaseFee(tb, sut, blocks)
+ return validBlocks, nil
+}
+
+func insertWithHeaderBaseFee(tb testing.TB, sut *SUT, bs types.Blocks) {
+ tb.Helper()
+ for _, b := range bs {
+ parent := sut.Chain.Last()
+ baseFee := b.BaseFee()
+ // TODO(cey): This is a hack to set the base fee to the block header base fee.
+ // Instead we should properly modify the test fixtures to apply expected base fee from the gasclock.
+ var wb *blocks.Block
+ if baseFee != nil {
+ wb = blockstest.WithFakeBaseFee(tb, sut.DB, parent, b, baseFee)
+ } else {
+ wb = blockstest.NewBlock(tb, b, parent, nil)
+ }
+ sut.Chain.Insert(tb, wb)
+ require.NoError(tb, sut.Enqueue(tb.Context(), wb))
+ require.NoError(tb, wb.WaitUntilExecuted(tb.Context()))
+ }
+}
+
+func validateHeader(h *btHeader, h2 *types.Header) error {
+ if h.Bloom != h2.Bloom {
+ return fmt.Errorf("bloom: want: %x have: %x", h.Bloom, h2.Bloom)
+ }
+ if h.Coinbase != h2.Coinbase {
+ return fmt.Errorf("coinbase: want: %x have: %x", h.Coinbase, h2.Coinbase)
+ }
+ if h.MixHash != h2.MixDigest {
+ return fmt.Errorf("MixHash: want: %x have: %x", h.MixHash, h2.MixDigest)
+ }
+ if h.Nonce != h2.Nonce {
+ return fmt.Errorf("nonce: want: %x have: %x", h.Nonce, h2.Nonce)
+ }
+ if h.Number.Cmp(h2.Number) != 0 {
+ return fmt.Errorf("number: want: %v have: %v", h.Number, h2.Number)
+ }
+ if h.ParentHash != h2.ParentHash {
+ return fmt.Errorf("parent hash: want: %x have: %x", h.ParentHash, h2.ParentHash)
+ }
+ if h.ReceiptTrie != h2.ReceiptHash {
+ return fmt.Errorf("receipt hash: want: %x have: %x", h.ReceiptTrie, h2.ReceiptHash)
+ }
+ if h.TransactionsTrie != h2.TxHash {
+ return fmt.Errorf("tx hash: want: %x have: %x", h.TransactionsTrie, h2.TxHash)
+ }
+ if h.StateRoot != h2.Root {
+ return fmt.Errorf("state hash: want: %x have: %x", h.StateRoot, h2.Root)
+ }
+ if h.UncleHash != h2.UncleHash {
+ return fmt.Errorf("uncle hash: want: %x have: %x", h.UncleHash, h2.UncleHash)
+ }
+ if !bytes.Equal(h.ExtraData, h2.Extra) {
+ return fmt.Errorf("extra data: want: %x have: %x", h.ExtraData, h2.Extra)
+ }
+ if h.Difficulty.Cmp(h2.Difficulty) != 0 {
+ return fmt.Errorf("difficulty: want: %v have: %v", h.Difficulty, h2.Difficulty)
+ }
+ if h.GasLimit != h2.GasLimit {
+ return fmt.Errorf("gasLimit: want: %d have: %d", h.GasLimit, h2.GasLimit)
+ }
+ if h.GasUsed != h2.GasUsed {
+ return fmt.Errorf("gasUsed: want: %d have: %d", h.GasUsed, h2.GasUsed)
+ }
+ if h.Timestamp != h2.Time {
+ return fmt.Errorf("timestamp: want: %v have: %v", h.Timestamp, h2.Time)
+ }
+ if !reflect.DeepEqual(h.BaseFeePerGas, h2.BaseFee) {
+ return fmt.Errorf("baseFeePerGas: want: %v have: %v", h.BaseFeePerGas, h2.BaseFee)
+ }
+ if !reflect.DeepEqual(h.WithdrawalsRoot, h2.WithdrawalsHash) {
+ return fmt.Errorf("withdrawalsRoot: want: %v have: %v", h.WithdrawalsRoot, h2.WithdrawalsHash)
+ }
+ if !reflect.DeepEqual(h.BlobGasUsed, h2.BlobGasUsed) {
+ return fmt.Errorf("blobGasUsed: want: %v have: %v", h.BlobGasUsed, h2.BlobGasUsed)
+ }
+ if !reflect.DeepEqual(h.ExcessBlobGas, h2.ExcessBlobGas) {
+ return fmt.Errorf("excessBlobGas: want: %v have: %v", h.ExcessBlobGas, h2.ExcessBlobGas)
+ }
+ if !reflect.DeepEqual(h.ParentBeaconBlockRoot, h2.ParentBeaconRoot) {
+ return fmt.Errorf("parentBeaconBlockRoot: want: %v have: %v", h.ParentBeaconBlockRoot, h2.ParentBeaconRoot)
+ }
+ return nil
+}
+
+func (t *BlockTest) validatePostState(statedb *state.StateDB) error {
+ // validate post state accounts in test file against what we have in state db
+ for addr, acct := range t.json.Post {
+ // address is indirectly verified by the other fields, as it's the db key
+ code2 := statedb.GetCode(addr)
+ balance2 := statedb.GetBalance(addr).ToBig()
+ nonce2 := statedb.GetNonce(addr)
+ if !bytes.Equal(code2, acct.Code) {
+ return fmt.Errorf("account code mismatch for addr: %s want: %v have: %s", addr, acct.Code, hex.EncodeToString(code2))
+ }
+ if balance2.Cmp(acct.Balance) != 0 {
+ return fmt.Errorf("account balance mismatch for addr: %s, want: %d, have: %d", addr, acct.Balance, balance2)
+ }
+ if nonce2 != acct.Nonce {
+ return fmt.Errorf("account nonce mismatch for addr: %s want: %d have: %d", addr, acct.Nonce, nonce2)
+ }
+ 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
+}
+
+func (t *BlockTest) validateImportedHeaders(cb *blockstest.ChainBuilder, validBlocks []btBlock) error {
+ // to get constant lookup when verifying block headers by hash (some tests have many blocks)
+ bmap := make(map[common.Hash]btBlock, len(t.json.Blocks))
+ for _, b := range validBlocks {
+ bmap[b.BlockHeader.Hash] = b
+ }
+ // iterate over blocks backwards from HEAD and validate imported
+ // headers vs test file. some tests have reorgs, and we import
+ // block-by-block, so we can only validate imported headers after
+ // all blocks have been processed by BlockChain, as they may not
+ // be part of the longest chain until last block is imported.
+ // Iterate backwards from the last block number to genesis (block 0 excluded)
+ lastBlock := cb.Last()
+ lastNumber := lastBlock.NumberU64()
+ for blockNumber := lastNumber; blockNumber > 0; blockNumber-- {
+ block, ok := cb.BlockByNumber(blockNumber)
+ if !ok {
+ return fmt.Errorf("block at height %d not found", blockNumber)
+ }
+ if err := validateHeader(bmap[block.Hash()].BlockHeader, block.Header()); err != nil {
+ return fmt.Errorf("imported block header validation failed: %v", err)
+ }
+ }
+ return nil
+}
+
+func (bb *btBlock) decode() (*types.Block, error) {
+ data, err := hexutil.Decode(bb.Rlp)
+ if err != nil {
+ return nil, err
+ }
+ var b types.Block
+ err = rlp.DecodeBytes(data, &b)
+ return &b, err
+}
diff --git a/saexec/ethtests/chain_header_reader.go b/saexec/ethtests/chain_header_reader.go
new file mode 100644
index 00000000..990e74f6
--- /dev/null
+++ b/saexec/ethtests/chain_header_reader.go
@@ -0,0 +1,80 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+
+package ethtests
+
+import (
+ "math/big"
+
+ "github.com/ava-labs/avalanchego/utils/logging"
+ "github.com/ava-labs/libevm/common"
+ "github.com/ava-labs/libevm/consensus"
+ "github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/ethdb"
+ "github.com/ava-labs/libevm/params"
+
+ "github.com/ava-labs/strevm/blocks/blockstest"
+)
+
+var _ consensus.ChainHeaderReader = (*readerAdapter)(nil)
+
+type readerAdapter struct {
+ chain *blockstest.ChainBuilder
+ db ethdb.Database
+ config *params.ChainConfig
+ logger logging.Logger
+}
+
+func newReaderAdapter(chain *blockstest.ChainBuilder, db ethdb.Database, cfg *params.ChainConfig, logger logging.Logger) *readerAdapter {
+ return &readerAdapter{
+ chain: chain,
+ db: db,
+ config: cfg,
+ logger: logger,
+ }
+}
+
+func (r *readerAdapter) Config() *params.ChainConfig {
+ return r.config
+}
+
+func (r *readerAdapter) GetHeader(hash common.Hash, number uint64) *types.Header {
+ b, ok := r.chain.GetBlock(hash, number)
+ if !ok {
+ return nil
+ }
+ return b.Header()
+}
+
+func (r *readerAdapter) CurrentHeader() *types.Header {
+ return r.chain.Last().Header()
+}
+
+func (r *readerAdapter) GetHeaderByHash(hash common.Hash) *types.Header {
+ b, ok := r.chain.BlockByHash(hash)
+ if !ok {
+ return nil
+ }
+ return b.Header()
+}
+
+func (r *readerAdapter) GetHeaderByNumber(number uint64) *types.Header {
+ b, ok := r.chain.BlockByNumber(number)
+ if !ok {
+ return nil
+ }
+ return b.Header()
+}
+
+func (r *readerAdapter) GetTd(hash common.Hash, number uint64) *big.Int {
+ td := rawdb.ReadTd(r.db, hash, number)
+ if td == nil {
+ return nil
+ }
+ return td
+}
+
+func (r *readerAdapter) SetTd(hash common.Hash, number uint64, td uint64) {
+ rawdb.WriteTd(r.db, hash, number, new(big.Int).SetUint64(td))
+}
diff --git a/saexec/ethtests/consensus_hooks.go b/saexec/ethtests/consensus_hooks.go
new file mode 100644
index 00000000..83525fe8
--- /dev/null
+++ b/saexec/ethtests/consensus_hooks.go
@@ -0,0 +1,103 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+
+package ethtests
+
+import (
+ "math/big"
+
+ "github.com/ava-labs/avalanchego/vms/components/gas"
+ "github.com/ava-labs/libevm/consensus"
+ "github.com/ava-labs/libevm/core"
+ "github.com/ava-labs/libevm/core/state"
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/core/vm"
+ "github.com/ava-labs/libevm/params"
+
+ "github.com/ava-labs/strevm/hook"
+)
+
+// consensusHooks implements [hook.Points].
+type consensusHooks struct {
+ consensus consensus.Engine
+ reader *readerAdapter
+ target gas.Gas
+}
+
+var _ hook.Points = (*consensusHooks)(nil)
+
+func (c *consensusHooks) BuildBlock(
+ header *types.Header,
+ txs []*types.Transaction,
+ receipts []*types.Receipt,
+) *types.Block {
+ return nil
+}
+
+func (c *consensusHooks) BuildHeader(parent *types.Header) *types.Header {
+ return nil
+}
+
+func newTestConsensusHooks(consensus consensus.Engine, reader *readerAdapter, target gas.Gas) *consensusHooks {
+ return &consensusHooks{consensus: consensus, reader: reader, target: target}
+}
+
+// BlockRebuilderFrom ignores its argument and always returns itself.
+func (c *consensusHooks) BlockRebuilderFrom(block *types.Block) hook.BlockBuilder {
+ return c
+}
+
+// GasTarget ignores its argument and always returns [consensusHooks.target].
+func (c *consensusHooks) GasTargetAfter(*types.Header) gas.Gas {
+ return c.target
+}
+
+// SubSecondBlockTime time ignores its argument and always returns 0.
+func (*consensusHooks) SubSecondBlockTime(gas.Gas, *types.Header) gas.Gas {
+ return 0
+}
+
+// EndOfBlockOps is a no-op.
+func (*consensusHooks) EndOfBlockOps(*types.Block) []hook.Op {
+ return nil
+}
+
+var _ core.ChainContext = (*chainContext)(nil)
+
+type chainContext struct {
+ engine consensus.Engine
+ *readerAdapter
+}
+
+func (c *chainContext) Engine() consensus.Engine {
+ return c.engine
+}
+
+// BeforeExecutingBlock processes the beacon block root if present.
+func (c *consensusHooks) BeforeExecutingBlock(_ params.Rules, statedb *state.StateDB, b *types.Block) error {
+ if beaconRoot := b.BeaconRoot(); beaconRoot != nil {
+ chainContext := &chainContext{engine: c.consensus, readerAdapter: c.reader}
+ context := core.NewEVMBlockContext(b.Header(), chainContext, nil)
+ vmenv := vm.NewEVM(context, vm.TxContext{}, statedb, chainContext.Config(), vm.Config{})
+ core.ProcessBeaconBlockRoot(*beaconRoot, vmenv, statedb)
+ }
+ return nil
+}
+
+// AfterExecutingBlock finalizes the block and updates the total difficulty.
+func (c *consensusHooks) AfterExecutingBlock(statedb *state.StateDB, b *types.Block, receipts types.Receipts) {
+ currentNumber := b.NumberU64()
+ currentTd := big.NewInt(0)
+ if currentNumber > 0 {
+ currentTd = c.reader.GetTd(b.ParentHash(), currentNumber-1)
+ if currentTd == nil {
+ currentTd = big.NewInt(0)
+ if c.reader.logger != nil {
+ c.reader.logger.Error("currentTd is nil")
+ }
+ }
+ }
+ newTd := new(big.Int).Add(currentTd, b.Difficulty())
+ c.reader.SetTd(b.Hash(), b.NumberU64(), newTd.Uint64())
+ c.consensus.Finalize(c.reader, b.Header(), statedb, b.Transactions(), b.Uncles(), b.Withdrawals())
+}
diff --git a/saexec/ethtests/gen_btheader.go b/saexec/ethtests/gen_btheader.go
new file mode 100644
index 00000000..025c57c5
--- /dev/null
+++ b/saexec/ethtests/gen_btheader.go
@@ -0,0 +1,160 @@
+// Code generated by github.com/fjl/gencodec. DO NOT EDIT.
+
+package ethtests
+
+import (
+ "encoding/json"
+ "math/big"
+
+ "github.com/ava-labs/libevm/common"
+ "github.com/ava-labs/libevm/common/hexutil"
+ "github.com/ava-labs/libevm/common/math"
+ "github.com/ava-labs/libevm/core/types"
+)
+
+var _ = (*btHeaderMarshaling)(nil)
+
+// MarshalJSON marshals as JSON.
+func (b btHeader) MarshalJSON() ([]byte, error) {
+ type btHeader struct {
+ Bloom types.Bloom
+ Coinbase common.Address
+ MixHash common.Hash
+ Nonce types.BlockNonce
+ Number *math.HexOrDecimal256
+ Hash common.Hash
+ ParentHash common.Hash
+ ReceiptTrie common.Hash
+ StateRoot common.Hash
+ TransactionsTrie common.Hash
+ UncleHash common.Hash
+ ExtraData hexutil.Bytes
+ Difficulty *math.HexOrDecimal256
+ GasLimit math.HexOrDecimal64
+ GasUsed math.HexOrDecimal64
+ Timestamp math.HexOrDecimal64
+ BaseFeePerGas *math.HexOrDecimal256
+ WithdrawalsRoot *common.Hash
+ BlobGasUsed *math.HexOrDecimal64
+ ExcessBlobGas *math.HexOrDecimal64
+ ParentBeaconBlockRoot *common.Hash
+ }
+ var enc btHeader
+ enc.Bloom = b.Bloom
+ enc.Coinbase = b.Coinbase
+ enc.MixHash = b.MixHash
+ enc.Nonce = b.Nonce
+ enc.Number = (*math.HexOrDecimal256)(b.Number)
+ enc.Hash = b.Hash
+ enc.ParentHash = b.ParentHash
+ enc.ReceiptTrie = b.ReceiptTrie
+ enc.StateRoot = b.StateRoot
+ enc.TransactionsTrie = b.TransactionsTrie
+ enc.UncleHash = b.UncleHash
+ enc.ExtraData = b.ExtraData
+ enc.Difficulty = (*math.HexOrDecimal256)(b.Difficulty)
+ enc.GasLimit = math.HexOrDecimal64(b.GasLimit)
+ enc.GasUsed = math.HexOrDecimal64(b.GasUsed)
+ enc.Timestamp = math.HexOrDecimal64(b.Timestamp)
+ enc.BaseFeePerGas = (*math.HexOrDecimal256)(b.BaseFeePerGas)
+ enc.WithdrawalsRoot = b.WithdrawalsRoot
+ enc.BlobGasUsed = (*math.HexOrDecimal64)(b.BlobGasUsed)
+ enc.ExcessBlobGas = (*math.HexOrDecimal64)(b.ExcessBlobGas)
+ enc.ParentBeaconBlockRoot = b.ParentBeaconBlockRoot
+ return json.Marshal(&enc)
+}
+
+// UnmarshalJSON unmarshals from JSON.
+func (b *btHeader) UnmarshalJSON(input []byte) error {
+ type btHeader struct {
+ Bloom *types.Bloom
+ Coinbase *common.Address
+ MixHash *common.Hash
+ Nonce *types.BlockNonce
+ Number *math.HexOrDecimal256
+ Hash *common.Hash
+ ParentHash *common.Hash
+ ReceiptTrie *common.Hash
+ StateRoot *common.Hash
+ TransactionsTrie *common.Hash
+ UncleHash *common.Hash
+ ExtraData *hexutil.Bytes
+ Difficulty *math.HexOrDecimal256
+ GasLimit *math.HexOrDecimal64
+ GasUsed *math.HexOrDecimal64
+ Timestamp *math.HexOrDecimal64
+ BaseFeePerGas *math.HexOrDecimal256
+ WithdrawalsRoot *common.Hash
+ BlobGasUsed *math.HexOrDecimal64
+ ExcessBlobGas *math.HexOrDecimal64
+ ParentBeaconBlockRoot *common.Hash
+ }
+ var dec btHeader
+ if err := json.Unmarshal(input, &dec); err != nil {
+ return err
+ }
+ if dec.Bloom != nil {
+ b.Bloom = *dec.Bloom
+ }
+ if dec.Coinbase != nil {
+ b.Coinbase = *dec.Coinbase
+ }
+ if dec.MixHash != nil {
+ b.MixHash = *dec.MixHash
+ }
+ if dec.Nonce != nil {
+ b.Nonce = *dec.Nonce
+ }
+ if dec.Number != nil {
+ b.Number = (*big.Int)(dec.Number)
+ }
+ if dec.Hash != nil {
+ b.Hash = *dec.Hash
+ }
+ if dec.ParentHash != nil {
+ b.ParentHash = *dec.ParentHash
+ }
+ if dec.ReceiptTrie != nil {
+ b.ReceiptTrie = *dec.ReceiptTrie
+ }
+ if dec.StateRoot != nil {
+ b.StateRoot = *dec.StateRoot
+ }
+ if dec.TransactionsTrie != nil {
+ b.TransactionsTrie = *dec.TransactionsTrie
+ }
+ if dec.UncleHash != nil {
+ b.UncleHash = *dec.UncleHash
+ }
+ if dec.ExtraData != nil {
+ b.ExtraData = *dec.ExtraData
+ }
+ if dec.Difficulty != nil {
+ b.Difficulty = (*big.Int)(dec.Difficulty)
+ }
+ if dec.GasLimit != nil {
+ b.GasLimit = uint64(*dec.GasLimit)
+ }
+ if dec.GasUsed != nil {
+ b.GasUsed = uint64(*dec.GasUsed)
+ }
+ if dec.Timestamp != nil {
+ b.Timestamp = uint64(*dec.Timestamp)
+ }
+ if dec.BaseFeePerGas != nil {
+ b.BaseFeePerGas = (*big.Int)(dec.BaseFeePerGas)
+ }
+ if dec.WithdrawalsRoot != nil {
+ b.WithdrawalsRoot = dec.WithdrawalsRoot
+ }
+ if dec.BlobGasUsed != nil {
+ b.BlobGasUsed = (*uint64)(dec.BlobGasUsed)
+ }
+ if dec.ExcessBlobGas != nil {
+ b.ExcessBlobGas = (*uint64)(dec.ExcessBlobGas)
+ }
+ if dec.ParentBeaconBlockRoot != nil {
+ b.ParentBeaconBlockRoot = dec.ParentBeaconBlockRoot
+ }
+ return nil
+}
diff --git a/saexec/ethtests/init.go b/saexec/ethtests/init.go
new file mode 100644
index 00000000..7cda05ba
--- /dev/null
+++ b/saexec/ethtests/init.go
@@ -0,0 +1,370 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+//
+// This file is a derived work, based on the go-ethereum library whose original
+// notices appear below.
+//
+// It is distributed under a license compatible with the licensing terms of the
+// original code from which it is derived.
+//
+// Much love to the original authors for their work.
+// **********
+// Copyright 2015 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 ethtests
+
+import (
+ "fmt"
+ "math/big"
+ "sort"
+
+ "github.com/ava-labs/libevm/params"
+)
+
+func u64(val uint64) *uint64 { return &val }
+
+// Forks table defines supported forks and their chain config.
+var Forks = map[string]*params.ChainConfig{
+ "Frontier": {
+ ChainID: big.NewInt(1),
+ },
+ "Homestead": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ },
+ "EIP150": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ },
+ "EIP158": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ },
+ "Byzantium": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ DAOForkBlock: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ },
+ "Constantinople": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ DAOForkBlock: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(10000000),
+ },
+ "ConstantinopleFix": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ DAOForkBlock: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ },
+ "Istanbul": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ DAOForkBlock: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ },
+ "MuirGlacier": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ DAOForkBlock: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ },
+ "FrontierToHomesteadAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(5),
+ },
+ "HomesteadToEIP150At5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(5),
+ },
+ "HomesteadToDaoAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ DAOForkBlock: big.NewInt(5),
+ DAOForkSupport: true,
+ },
+ "EIP158ToByzantiumAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(5),
+ },
+ "ByzantiumToConstantinopleAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(5),
+ },
+ "ByzantiumToConstantinopleFixAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(5),
+ PetersburgBlock: big.NewInt(5),
+ },
+ "ConstantinopleFixToIstanbulAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(5),
+ },
+ "Berlin": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ },
+ "BerlinToLondonAt5": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(5),
+ },
+ "London": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ },
+ "ArrowGlacier": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ },
+ "ArrowGlacierToMergeAtDiffC0000": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ GrayGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0xC0000),
+ },
+ "GrayGlacier": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ GrayGlacierBlock: big.NewInt(0),
+ },
+ "Merge": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0),
+ },
+ "Shanghai": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0),
+ ShanghaiTime: u64(0),
+ },
+ "MergeToShanghaiAtTime15k": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0),
+ ShanghaiTime: u64(15_000),
+ },
+ "Cancun": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0),
+ ShanghaiTime: u64(0),
+ CancunTime: u64(0),
+ },
+ "ShanghaiToCancunAtTime15k": {
+ ChainID: big.NewInt(1),
+ HomesteadBlock: big.NewInt(0),
+ EIP150Block: big.NewInt(0),
+ EIP155Block: big.NewInt(0),
+ EIP158Block: big.NewInt(0),
+ ByzantiumBlock: big.NewInt(0),
+ ConstantinopleBlock: big.NewInt(0),
+ PetersburgBlock: big.NewInt(0),
+ IstanbulBlock: big.NewInt(0),
+ MuirGlacierBlock: big.NewInt(0),
+ BerlinBlock: big.NewInt(0),
+ LondonBlock: big.NewInt(0),
+ ArrowGlacierBlock: big.NewInt(0),
+ MergeNetsplitBlock: big.NewInt(0),
+ TerminalTotalDifficulty: big.NewInt(0),
+ ShanghaiTime: u64(0),
+ CancunTime: u64(15_000),
+ },
+}
+
+// AvailableForks returns the set of defined fork names
+func AvailableForks() []string {
+ var availableForks []string
+ for k := range Forks {
+ availableForks = append(availableForks, k)
+ }
+ sort.Strings(availableForks)
+ return availableForks
+}
+
+// UnsupportedForkError is returned when a test requests a fork that isn't implemented.
+type UnsupportedForkError struct {
+ Name string
+}
+
+func (e UnsupportedForkError) Error() string {
+ return fmt.Sprintf("unsupported fork %q", e.Name)
+}
diff --git a/saexec/ethtests/init_test.go b/saexec/ethtests/init_test.go
new file mode 100644
index 00000000..547a2901
--- /dev/null
+++ b/saexec/ethtests/init_test.go
@@ -0,0 +1,263 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+//
+// This file is a derived work, based on the go-ethereum library whose original
+// notices appear below.
+//
+// It is distributed under a license compatible with the licensing terms of the
+// original code from which it is derived.
+//
+// Much love to the original authors for their work.
+// **********
+// Copyright 2017 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 ethtests
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+)
+
+var (
+ baseDir = filepath.Join(".", "testdata")
+ blockTestDir = filepath.Join(baseDir, "BlockchainTests")
+ legacyBlockTestDir = filepath.Join(baseDir, "LegacyTests", "Constantinople", "BlockchainTests")
+ executionSpecBlockchainTestDir = filepath.Join(".", "spec-tests", "fixtures", "blockchain_tests")
+)
+
+func readJSON(reader io.Reader, value interface{}) error {
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ return fmt.Errorf("error reading JSON file: %v", err)
+ }
+ if err = json.Unmarshal(data, &value); err != nil {
+ if syntaxerr, ok := err.(*json.SyntaxError); ok {
+ line := findLine(data, syntaxerr.Offset)
+ return fmt.Errorf("JSON syntax error at line %v: %v", line, err)
+ }
+ return err
+ }
+ return nil
+}
+
+func readJSONFile(fn string, value interface{}) error {
+ file, err := os.Open(fn) //nolint:gosec // We control the file path
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ err = readJSON(file, value)
+ if err != nil {
+ return fmt.Errorf("%s in file %s", err.Error(), fn)
+ }
+ return nil
+}
+
+// findLine returns the line number for the given offset into data.
+func findLine(data []byte, offset int64) (line int) {
+ line = 1
+ for i, r := range string(data) {
+ if int64(i) >= offset {
+ return
+ }
+ if r == '\n' {
+ line++
+ }
+ }
+ return
+}
+
+// testMatcher controls skipping and chain config assignment to tests.
+type testMatcher struct {
+ failpat []testFailure
+ skiploadpat []*regexp.Regexp
+ slowpat []*regexp.Regexp
+ runonlylistpat *regexp.Regexp
+}
+
+type testFailure struct {
+ p *regexp.Regexp
+ reason string
+}
+
+// skipShortMode skips tests matching when the -short flag is used.
+func (tm *testMatcher) slow(pattern string) {
+ tm.slowpat = append(tm.slowpat, regexp.MustCompile(pattern))
+}
+
+// skipLoad skips JSON loading of tests matching the pattern.
+func (tm *testMatcher) skipLoad(pattern string) {
+ tm.skiploadpat = append(tm.skiploadpat, regexp.MustCompile(pattern))
+}
+
+// fails adds an expected failure for tests matching the pattern.
+//
+//nolint:unused
+func (tm *testMatcher) fails(pattern string, reason string) {
+ if reason == "" {
+ panic("empty fail reason")
+ }
+ tm.failpat = append(tm.failpat, testFailure{regexp.MustCompile(pattern), reason})
+}
+
+// findSkip matches name against test skip patterns.
+func (tm *testMatcher) findSkip(name string) (reason string, skipload bool) {
+ isWin32 := runtime.GOARCH == "386" && runtime.GOOS == "windows"
+ for _, re := range tm.slowpat {
+ if re.MatchString(name) {
+ if testing.Short() {
+ return "skipped in -short mode", false
+ }
+ if isWin32 {
+ return "skipped on 32bit windows", false
+ }
+ }
+ }
+ for _, re := range tm.skiploadpat {
+ if re.MatchString(name) {
+ return "skipped by skipLoad", true
+ }
+ }
+ return "", false
+}
+
+// checkFailure checks whether a failure is expected.
+func (tm *testMatcher) checkFailure(t *testing.T, err error) error {
+ t.Helper()
+ failReason := ""
+ for _, m := range tm.failpat {
+ if m.p.MatchString(t.Name()) {
+ failReason = m.reason
+ break
+ }
+ }
+ if failReason != "" {
+ t.Logf("expected failure: %s", failReason)
+ if err != nil {
+ t.Logf("error: %v", err)
+ return nil
+ }
+ return errors.New("test succeeded unexpectedly")
+ }
+ return err
+}
+
+// walk invokes its runTest argument for all subtests in the given directory.
+//
+// runTest should be a function of type func(t *testing.T, name string, x ),
+// where TestType is the type of the test contained in test files.
+func (tm *testMatcher) walk(t *testing.T, dir string, runTest interface{}) {
+ t.Helper()
+ // Walk the directory.
+ dirinfo, err := os.Stat(dir)
+ if os.IsNotExist(err) || !dirinfo.IsDir() {
+ fmt.Fprintf(os.Stderr, "can't find test files in %s, did you clone the tests submodule?\n", dir)
+ t.Skip("missing test files")
+ }
+ err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ name := filepath.ToSlash(strings.TrimPrefix(path, dir+string(filepath.Separator)))
+ if info.IsDir() {
+ if _, skipload := tm.findSkip(name + "/"); skipload {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if filepath.Ext(path) == ".json" {
+ t.Run(name, func(t *testing.T) { tm.runTestFile(t, path, name, runTest) })
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (tm *testMatcher) runTestFile(t *testing.T, path, name string, runTest interface{}) {
+ t.Helper()
+ if r, _ := tm.findSkip(name); r != "" {
+ t.Skip(r)
+ }
+ if tm.runonlylistpat != nil {
+ if !tm.runonlylistpat.MatchString(name) {
+ t.Skip("Skipped by runonly")
+ }
+ }
+ t.Parallel()
+
+ // Load the file as map[string].
+ m := makeMapFromTestFunc(runTest)
+ if err := readJSONFile(path, m.Addr().Interface()); err != nil {
+ t.Fatal(err)
+ }
+
+ // Run all tests from the map. Don't wrap in a subtest if there is only one test in the file.
+ keys := sortedMapKeys(m)
+ if len(keys) == 1 {
+ runTestFunc(t, runTest, name, m, keys[0])
+ } else {
+ for _, key := range keys {
+ name := name + "/" + key
+ t.Run(key, func(t *testing.T) {
+ if r, _ := tm.findSkip(name); r != "" {
+ t.Skip(r)
+ }
+ runTestFunc(t, runTest, name, m, key)
+ })
+ }
+ }
+}
+
+func makeMapFromTestFunc(f interface{}) reflect.Value {
+ stringT := reflect.TypeOf("")
+ testingT := reflect.TypeOf((*testing.T)(nil))
+ ftyp := reflect.TypeOf(f)
+ if ftyp.Kind() != reflect.Func || ftyp.NumIn() != 3 || ftyp.NumOut() != 0 || ftyp.In(0) != testingT || ftyp.In(1) != stringT {
+ panic(fmt.Sprintf("bad test function type: want func(*testing.T, string, ), have %s", ftyp))
+ }
+ testType := ftyp.In(2)
+ mp := reflect.New(reflect.MapOf(stringT, testType))
+ return mp.Elem()
+}
+
+func sortedMapKeys(m reflect.Value) []string {
+ keys := make([]string, m.Len())
+ for i, k := range m.MapKeys() {
+ keys[i] = k.String()
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func runTestFunc(t *testing.T, runTest interface{}, name string, m reflect.Value, key string) {
+ t.Helper()
+ reflect.ValueOf(runTest).Call([]reflect.Value{
+ reflect.ValueOf(t),
+ reflect.ValueOf(name),
+ m.MapIndex(reflect.ValueOf(key)),
+ })
+}
diff --git a/saexec/ethtests/sut.go b/saexec/ethtests/sut.go
new file mode 100644
index 00000000..787ddc96
--- /dev/null
+++ b/saexec/ethtests/sut.go
@@ -0,0 +1,129 @@
+// Copyright (C) 2025-2026, Ava Labs, Inc. All rights reserved.
+// See the file LICENSE for licensing terms.
+
+package ethtests
+
+import (
+ "context"
+ "testing"
+
+ "github.com/ava-labs/avalanchego/utils/logging"
+ "github.com/ava-labs/avalanchego/vms/components/gas"
+ "github.com/ava-labs/libevm/consensus"
+ "github.com/ava-labs/libevm/core"
+ "github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/state/snapshot"
+ "github.com/ava-labs/libevm/core/types"
+ "github.com/ava-labs/libevm/ethdb"
+ "github.com/ava-labs/libevm/libevm/options"
+ "github.com/ava-labs/libevm/params"
+ "github.com/ava-labs/libevm/triedb"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ava-labs/strevm/blocks/blockstest"
+ "github.com/ava-labs/strevm/saetest"
+ "github.com/ava-labs/strevm/saexec"
+)
+
+// SUT is the system under test, primarily the [Executor].
+type SUT struct {
+ *saexec.Executor
+ Chain *blockstest.ChainBuilder
+ Wallet *saetest.Wallet
+ Logger *saetest.TBLogger
+ DB ethdb.Database
+}
+
+type sutOptions struct {
+ triedbConfig *triedb.Config
+ genesisSpec *core.Genesis
+ chainConfig *params.ChainConfig
+ snapshotConfig *snapshot.Config
+}
+
+type sutOption = options.Option[sutOptions]
+
+func withTrieDBConfig(tdbConfig *triedb.Config) sutOption {
+ return options.Func[sutOptions](func(o *sutOptions) {
+ o.triedbConfig = tdbConfig
+ })
+}
+
+func withGenesisSpec(genesisSpec *core.Genesis) sutOption {
+ return options.Func[sutOptions](func(o *sutOptions) {
+ o.genesisSpec = genesisSpec
+ })
+}
+
+func withChainConfig(chainConfig *params.ChainConfig) sutOption {
+ return options.Func[sutOptions](func(o *sutOptions) {
+ o.chainConfig = chainConfig
+ })
+}
+
+func withSnapshotConfig(snapshotConfig *snapshot.Config) sutOption {
+ return options.Func[sutOptions](func(o *sutOptions) {
+ o.snapshotConfig = snapshotConfig
+ })
+}
+
+// newSUT returns a new SUT. Any >= [logging.Error] on the logger will also
+// cancel the returned context, which is useful when waiting for blocks that
+// can never finish execution because of an error.
+func newSUT(tb testing.TB, engine consensus.Engine, opts ...sutOption) (context.Context, SUT) {
+ tb.Helper()
+
+ // This is specifically set to [logging.Error] to ensure that the warn log in execution queue
+ // does not cause the test to fail.
+ logger := saetest.NewTBLogger(tb, logging.Error)
+ ctx := logger.CancelOnError(tb.Context())
+ db := rawdb.NewMemoryDatabase()
+
+ conf := options.ApplyTo(&sutOptions{}, opts...)
+ chainConfig := conf.chainConfig
+ if chainConfig == nil {
+ chainConfig = saetest.ChainConfig()
+ }
+ tdbConfig := conf.triedbConfig
+ if tdbConfig == nil {
+ tdbConfig = &triedb.Config{}
+ }
+ wallet := saetest.NewUNSAFEWallet(tb, 1, types.LatestSigner(chainConfig))
+ alloc := saetest.MaxAllocFor(wallet.Addresses()...)
+ snapshotConfig := conf.snapshotConfig
+ if snapshotConfig == nil {
+ snapshotConfig = &snapshot.Config{
+ CacheSize: 128, // MB
+ AsyncBuild: true,
+ }
+ }
+
+ target := gas.Gas(1e6)
+ genesisOpts := []blockstest.GenesisOption{blockstest.WithTrieDBConfig(tdbConfig), blockstest.WithGasTarget(target)}
+ if conf.genesisSpec != nil {
+ genesisOpts = append(genesisOpts, blockstest.WithGenesisSpec(conf.genesisSpec))
+ }
+
+ genesis := blockstest.NewGenesis(tb, db, chainConfig, alloc, genesisOpts...)
+
+ blockOpts := blockstest.WithBlockOptions(
+ blockstest.WithLogger(logger),
+ )
+ chain := blockstest.NewChainBuilder(chainConfig, genesis, blockOpts)
+
+ reader := newReaderAdapter(chain, db, chainConfig, logger)
+ hooks := newTestConsensusHooks(engine, reader, target)
+ e, err := saexec.New(genesis, chain.GetBlock, chainConfig, db, tdbConfig, *snapshotConfig, hooks, logger)
+ require.NoError(tb, err, "New()")
+ tb.Cleanup(func() {
+ require.NoErrorf(tb, e.Close(), "%T.Close()", e)
+ })
+
+ return ctx, SUT{
+ Executor: e,
+ Chain: chain,
+ Wallet: wallet,
+ Logger: logger,
+ DB: db,
+ }
+}
diff --git a/saexec/ethtests/testdata b/saexec/ethtests/testdata
new file mode 160000
index 00000000..fa51c5c1
--- /dev/null
+++ b/saexec/ethtests/testdata
@@ -0,0 +1 @@
+Subproject commit fa51c5c164f79140730ccb8fe26a46c3d3994338
diff --git a/saexec/saexec.go b/saexec/saexec.go
index dc45d909..94532345 100644
--- a/saexec/saexec.go
+++ b/saexec/saexec.go
@@ -59,16 +59,14 @@ func New(
blockSrc blocks.Source,
chainConfig *params.ChainConfig,
db ethdb.Database,
+ // TODO(cey): should these be an option?
triedbConfig *triedb.Config,
+ snapshotConfig snapshot.Config,
hooks hook.Points,
log logging.Logger,
) (*Executor, error) {
cache := state.NewDatabaseWithConfig(db, triedbConfig)
- snapConf := snapshot.Config{
- CacheSize: 128, // MB
- AsyncBuild: true,
- }
- snaps, err := snapshot.New(snapConf, db, cache.TrieDB(), lastExecuted.PostExecutionStateRoot())
+ snaps, err := snapshot.New(snapshotConfig, db, cache.TrieDB(), lastExecuted.PostExecutionStateRoot())
if err != nil {
return nil, err
}
@@ -131,3 +129,14 @@ func (e *Executor) LastExecuted() *blocks.Block {
func (e *Executor) LastEnqueued() *blocks.Block {
return e.lastEnqueued.Load()
}
+
+// RefreshQuit replaces the quit channel with a new one. This is used to
+// refresh the quit channel after a test has completed. Should only be used in tests.
+func (e *Executor) RefreshQuit() {
+ e.quit = make(chan struct{})
+}
+
+// Snapshots returns the snapshot tree.
+func (e *Executor) Snapshots() *snapshot.Tree {
+ return e.snaps
+}
diff --git a/saexec/saexec_test.go b/saexec/saexec_test.go
index 854e1ca6..3cd3fcc2 100644
--- a/saexec/saexec_test.go
+++ b/saexec/saexec_test.go
@@ -86,8 +86,8 @@ func newSUT(tb testing.TB, hooks *saehookstest.Stub) (context.Context, SUT) {
blockstest.WithLogger(logger),
)
chain := blockstest.NewChainBuilder(config, genesis, opts)
-
- e, err := New(genesis, chain.GetBlock, config, db, tdbConfig, hooks, logger)
+ snapshotConfig := snapshot.Config{CacheSize: 128, AsyncBuild: true}
+ e, err := New(genesis, chain.GetBlock, config, db, tdbConfig, snapshotConfig, hooks, logger)
require.NoError(tb, err, "New()")
tb.Cleanup(func() {
require.NoErrorf(tb, e.Close(), "%T.Close()", e)
diff --git a/txgossip/txgossip_test.go b/txgossip/txgossip_test.go
index 00215e6b..89d29d24 100644
--- a/txgossip/txgossip_test.go
+++ b/txgossip/txgossip_test.go
@@ -22,6 +22,7 @@ import (
"github.com/ava-labs/avalanchego/utils/logging"
"github.com/ava-labs/libevm/common"
"github.com/ava-labs/libevm/core/rawdb"
+ "github.com/ava-labs/libevm/core/state/snapshot"
"github.com/ava-labs/libevm/core/txpool"
"github.com/ava-labs/libevm/core/txpool/legacypool"
"github.com/ava-labs/libevm/core/types"
@@ -78,7 +79,9 @@ func newSUT(t *testing.T, numAccounts uint) SUT {
genesis := blockstest.NewGenesis(t, db, config, saetest.MaxAllocFor(wallet.Addresses()...))
chain := blockstest.NewChainBuilder(config, genesis)
- exec, err := saexec.New(genesis, chain.GetBlock, config, db, nil, &hookstest.Stub{Target: 1e6}, logger)
+ snapshotConfig := snapshot.Config{CacheSize: 128, AsyncBuild: true}
+
+ exec, err := saexec.New(genesis, chain.GetBlock, config, db, nil, snapshotConfig, &hookstest.Stub{Target: 1e6}, logger)
require.NoError(t, err, "saexec.New()")
t.Cleanup(func() {
require.NoErrorf(t, exec.Close(), "%T.Close()", exec)