diff --git a/beacon/engine/types.go b/beacon/engine/types.go
index da9b6568f2..206bc02b0c 100644
--- a/beacon/engine/types.go
+++ b/beacon/engine/types.go
@@ -213,7 +213,7 @@ func encodeTransactions(txs []*types.Transaction) [][]byte {
return enc
}
-func decodeTransactions(enc [][]byte) ([]*types.Transaction, error) {
+func DecodeTransactions(enc [][]byte) ([]*types.Transaction, error) {
var txs = make([]*types.Transaction, len(enc))
for i, encTx := range enc {
var tx types.Transaction
@@ -251,7 +251,7 @@ func ExecutableDataToBlock(data ExecutableData, versionedHashes []common.Hash, b
// for stateless execution, so it skips checking if the executable data hashes to
// the requested hash (stateless has to *compute* the root hash, it's not given).
func ExecutableDataToBlockNoHash(data ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash, requests [][]byte) (*types.Block, error) {
- txs, err := decodeTransactions(data.Transactions)
+ txs, err := DecodeTransactions(data.Transactions)
if err != nil {
return nil, err
}
diff --git a/cmd/geth/consolecmd_test.go b/cmd/geth/consolecmd_test.go
index 871e8c175f..12ee7e7dd1 100644
--- a/cmd/geth/consolecmd_test.go
+++ b/cmd/geth/consolecmd_test.go
@@ -30,7 +30,7 @@ import (
)
const (
- ipcAPIs = "admin:1.0 debug:1.0 engine:1.0 eth:1.0 miner:1.0 net:1.0 rpc:1.0 txpool:1.0 web3:1.0"
+ ipcAPIs = "admin:1.0 debug:1.0 engine:1.0 eth:1.0 miner:1.0 net:1.0 rpc:1.0 testing:1.0 txpool:1.0 web3:1.0"
httpAPIs = "eth:1.0 net:1.0 rpc:1.0 web3:1.0"
)
diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go
index 1850e4ce40..b38d8fd5bf 100644
--- a/eth/catalyst/api.go
+++ b/eth/catalyst/api.go
@@ -47,9 +47,10 @@ import (
"github.com/ethereum/go-ethereum/rpc"
)
-// Register adds the engine API to the full node.
+// Register adds the engine API and related APIs to the full node.
func Register(stack *node.Node, backend *eth.Ethereum) error {
stack.RegisterAPIs([]rpc.API{
+ newTestingAPI(backend),
{
Namespace: "engine",
Service: NewConsensusAPI(backend),
diff --git a/eth/catalyst/api_testing.go b/eth/catalyst/api_testing.go
new file mode 100644
index 0000000000..8586029468
--- /dev/null
+++ b/eth/catalyst/api_testing.go
@@ -0,0 +1,79 @@
+// Copyright 2026 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 catalyst
+
+import (
+ "errors"
+
+ "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/core/types"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/miner"
+ "github.com/ethereum/go-ethereum/rpc"
+)
+
+// testingAPI implements the testing_ namespace.
+// It's an engine-API adjacent namespace for testing purposes.
+type testingAPI struct {
+ eth *eth.Ethereum
+}
+
+func newTestingAPI(backend *eth.Ethereum) rpc.API {
+ return rpc.API{
+ Namespace: "testing",
+ Service: &testingAPI{backend},
+ Version: "1.0",
+ Authenticated: false,
+ }
+}
+
+func (api *testingAPI) BuildBlockV1(parentHash common.Hash, payloadAttributes engine.PayloadAttributes, transactions *[]hexutil.Bytes, extraData *hexutil.Bytes) (*engine.ExecutionPayloadEnvelope, error) {
+ if api.eth.BlockChain().CurrentBlock().Hash() != parentHash {
+ return nil, errors.New("parentHash is not current head")
+ }
+ // If transactions is empty but not nil, build an empty block
+ // If the transactions is nil, build a block with the current transactions from the txpool
+ // If the transactions is not nil and not empty, build a block with the transactions
+ buildEmpty := transactions != nil && len(*transactions) == 0
+ var txs []*types.Transaction
+ if transactions != nil {
+ dec := make([][]byte, 0, len(*transactions))
+ for _, tx := range *transactions {
+ dec = append(dec, tx)
+ }
+ var err error
+ txs, err = engine.DecodeTransactions(dec)
+ if err != nil {
+ return nil, err
+ }
+ }
+ extra := make([]byte, 0)
+ if extraData != nil {
+ extra = *extraData
+ }
+ args := &miner.BuildPayloadArgs{
+ Parent: parentHash,
+ Timestamp: payloadAttributes.Timestamp,
+ FeeRecipient: payloadAttributes.SuggestedFeeRecipient,
+ Random: payloadAttributes.Random,
+ Withdrawals: payloadAttributes.Withdrawals,
+ BeaconRoot: payloadAttributes.BeaconRoot,
+ }
+ return api.eth.Miner().BuildTestingPayload(args, txs, buildEmpty, extra)
+}
diff --git a/eth/catalyst/api_testing_test.go b/eth/catalyst/api_testing_test.go
new file mode 100644
index 0000000000..fd4d28d26a
--- /dev/null
+++ b/eth/catalyst/api_testing_test.go
@@ -0,0 +1,121 @@
+// Copyright 2026 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 catalyst
+
+import (
+ "context"
+ "math/big"
+ "testing"
+
+ "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/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+func TestBuildBlockV1(t *testing.T) {
+ genesis, blocks := generateMergeChain(5, true)
+ n, ethservice := startEthService(t, genesis, blocks)
+ defer n.Close()
+
+ parent := ethservice.BlockChain().CurrentBlock()
+ attrs := engine.PayloadAttributes{
+ Timestamp: parent.Time + 1,
+ Random: crypto.Keccak256Hash([]byte("test")),
+ SuggestedFeeRecipient: parent.Coinbase,
+ Withdrawals: nil,
+ BeaconRoot: nil,
+ }
+
+ currentNonce, _ := ethservice.APIBackend.GetPoolNonce(context.Background(), testAddr)
+ tx, _ := types.SignTx(types.NewTransaction(currentNonce, testAddr, big.NewInt(1), params.TxGas, big.NewInt(params.InitialBaseFee*2), nil), types.LatestSigner(ethservice.BlockChain().Config()), testKey)
+
+ api := &testingAPI{eth: ethservice}
+
+ t.Run("buildOnCurrentHead", func(t *testing.T) {
+ envelope, err := api.BuildBlockV1(parent.Hash(), attrs, nil, nil)
+ if err != nil {
+ t.Fatalf("BuildBlockV1 failed: %v", err)
+ }
+ if envelope == nil || envelope.ExecutionPayload == nil {
+ t.Fatal("expected non-nil envelope and payload")
+ }
+ payload := envelope.ExecutionPayload
+ if payload.ParentHash != parent.Hash() {
+ t.Errorf("parent hash mismatch: got %x want %x", payload.ParentHash, parent.Hash())
+ }
+ if payload.Number != parent.Number.Uint64()+1 {
+ t.Errorf("block number mismatch: got %d want %d", payload.Number, parent.Number.Uint64()+1)
+ }
+ if payload.Timestamp != attrs.Timestamp {
+ t.Errorf("timestamp mismatch: got %d want %d", payload.Timestamp, attrs.Timestamp)
+ }
+ if payload.FeeRecipient != attrs.SuggestedFeeRecipient {
+ t.Errorf("fee recipient mismatch: got %x want %x", payload.FeeRecipient, attrs.SuggestedFeeRecipient)
+ }
+ })
+
+ t.Run("wrongParentHash", func(t *testing.T) {
+ wrongParent := common.Hash{0x01}
+ _, err := api.BuildBlockV1(wrongParent, attrs, nil, nil)
+ if err == nil {
+ t.Fatal("expected error when parentHash is not current head")
+ }
+ if err.Error() != "parentHash is not current head" {
+ t.Errorf("unexpected error: %v", err)
+ }
+ })
+
+ t.Run("buildEmptyBlock", func(t *testing.T) {
+ emptyTxs := []hexutil.Bytes{}
+ envelope, err := api.BuildBlockV1(parent.Hash(), attrs, &emptyTxs, nil)
+ if err != nil {
+ t.Fatalf("BuildBlockV1 with empty txs failed: %v", err)
+ }
+ if envelope == nil || envelope.ExecutionPayload == nil {
+ t.Fatal("expected non-nil envelope and payload")
+ }
+ if len(envelope.ExecutionPayload.Transactions) != 0 {
+ t.Errorf("expected empty block, got %d transactions", len(envelope.ExecutionPayload.Transactions))
+ }
+ })
+
+ t.Run("buildBlockWithTransactions", func(t *testing.T) {
+ enc, _ := tx.MarshalBinary()
+ txs := []hexutil.Bytes{enc}
+ envelope, err := api.BuildBlockV1(parent.Hash(), attrs, &txs, nil)
+ if err != nil {
+ t.Fatalf("BuildBlockV1 with transaction failed: %v", err)
+ }
+ if len(envelope.ExecutionPayload.Transactions) != 1 {
+ t.Errorf("expected 1 transaction, got %d", len(envelope.ExecutionPayload.Transactions))
+ }
+ })
+
+ t.Run("buildBlockWithTransactionsFromTxPool", func(t *testing.T) {
+ ethservice.TxPool().Add([]*types.Transaction{tx}, true)
+ envelope, err := api.BuildBlockV1(parent.Hash(), attrs, nil, nil)
+ if err != nil {
+ t.Fatalf("BuildBlockV1 with transaction failed: %v", err)
+ }
+ if len(envelope.ExecutionPayload.Transactions) != 1 {
+ t.Errorf("expected 1 transaction, got %d", len(envelope.ExecutionPayload.Transactions))
+ }
+ })
+}
diff --git a/eth/catalyst/simulated_beacon.go b/eth/catalyst/simulated_beacon.go
index 25d8b7df78..ed3fa76a57 100644
--- a/eth/catalyst/simulated_beacon.go
+++ b/eth/catalyst/simulated_beacon.go
@@ -376,5 +376,6 @@ func RegisterSimulatedBeaconAPIs(stack *node.Node, sim *SimulatedBeacon) {
Service: api,
Version: "1.0",
},
+ newTestingAPI(sim.eth),
})
}
diff --git a/eth/tracers/logger/access_list_tracer.go b/eth/tracers/logger/access_list_tracer.go
index 0d51f40522..2e51a9a907 100644
--- a/eth/tracers/logger/access_list_tracer.go
+++ b/eth/tracers/logger/access_list_tracer.go
@@ -18,6 +18,7 @@ package logger
import (
"maps"
+ "slices"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/tracing"
@@ -88,8 +89,10 @@ func (al accessList) accessList() types.AccessList {
for slot := range slots {
tuple.StorageKeys = append(tuple.StorageKeys, slot)
}
- acl = append(acl, tuple)
+ keys := slices.SortedFunc(maps.Keys(slots), common.Hash.Cmp)
+ acl = append(acl, types.AccessTuple{Address: addr, StorageKeys: keys})
}
+ slices.SortFunc(acl, func(a, b types.AccessTuple) int { return a.Address.Cmp(b.Address) })
return acl
}
diff --git a/internal/ethapi/override/override.go b/internal/ethapi/override/override.go
index 9d57a78651..96ba77ab0a 100644
--- a/internal/ethapi/override/override.go
+++ b/internal/ethapi/override/override.go
@@ -19,7 +19,9 @@ package override
import (
"errors"
"fmt"
+ "maps"
"math/big"
+ "slices"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
@@ -58,9 +60,13 @@ func (diff *StateOverride) Apply(statedb *state.StateDB, precompiles vm.Precompi
if diff == nil {
return nil
}
+ // Iterate in deterministic order so error messages and behavior are stable (e.g. for tests).
+ addrs := slices.SortedFunc(maps.Keys(*diff), common.Address.Cmp)
+
// Tracks destinations of precompiles that were moved.
dirtyAddrs := make(map[common.Address]struct{})
- for addr, account := range *diff {
+ for _, addr := range addrs {
+ account := (*diff)[addr]
// If a precompile was moved to this address already, it can't be overridden.
if _, ok := dirtyAddrs[addr]; ok {
return fmt.Errorf("account %s has already been overridden by a precompile", addr.Hex())
diff --git a/miner/payload_building.go b/miner/payload_building.go
index 6b010186bf..a049ce190a 100644
--- a/miner/payload_building.go
+++ b/miner/payload_building.go
@@ -273,3 +273,26 @@ func (miner *Miner) buildPayload(args *BuildPayloadArgs, witness bool) (*Payload
}()
return payload, nil
}
+
+// BuildTestingPayload is for testing_buildBlockV*. It creates a block with the exact content given
+// by the parameters instead of using the locally available transactions.
+func (miner *Miner) BuildTestingPayload(args *BuildPayloadArgs, transactions []*types.Transaction, empty bool, extraData []byte) (*engine.ExecutionPayloadEnvelope, error) {
+ fullParams := &generateParams{
+ timestamp: args.Timestamp,
+ forceTime: true,
+ parentHash: args.Parent,
+ coinbase: args.FeeRecipient,
+ random: args.Random,
+ withdrawals: args.Withdrawals,
+ beaconRoot: args.BeaconRoot,
+ noTxs: empty,
+ forceOverrides: true,
+ overrideExtraData: extraData,
+ overrideTxs: transactions,
+ }
+ res := miner.generateWork(fullParams, false)
+ if res.err != nil {
+ return nil, res.err
+ }
+ return engine.BlockToExecutableData(res.block, new(big.Int), res.sidecars, res.requests), nil
+}
diff --git a/miner/worker.go b/miner/worker.go
index 45d7073ed7..9e2140bd04 100644
--- a/miner/worker.go
+++ b/miner/worker.go
@@ -112,6 +112,10 @@ type generateParams struct {
withdrawals types.Withdrawals // List of withdrawals to include in block (shanghai field)
beaconRoot *common.Hash // The beacon root (cancun field).
noTxs bool // Flag whether an empty block without any transaction is expected
+
+ forceOverrides bool // Flag whether we should overwrite extraData and transactions
+ overrideExtraData []byte
+ overrideTxs []*types.Transaction
}
// generateWork generates a sealing block based on the given parameters.
@@ -132,15 +136,30 @@ func (miner *Miner) generateWork(genParam *generateParams, witness bool) *newPay
work.size += uint64(genParam.withdrawals.Size())
if !genParam.noTxs {
- interrupt := new(atomic.Int32)
- timer := time.AfterFunc(miner.config.Recommit, func() {
- interrupt.Store(commitInterruptTimeout)
- })
- defer timer.Stop()
+ // If forceOverrides is true and overrideTxs is not empty, commit the override transactions
+ // otherwise, fill the block with the current transactions from the txpool
+ if genParam.forceOverrides && len(genParam.overrideTxs) > 0 {
+ if work.gasPool == nil {
+ work.gasPool = new(core.GasPool).AddGas(work.header.GasLimit)
+ }
+ for _, tx := range genParam.overrideTxs {
+ work.state.SetTxContext(tx.Hash(), work.tcount)
+ if err := miner.commitTransaction(work, tx); err != nil {
+ // all passed transactions HAVE to be valid at this point
+ return &newPayloadResult{err: err}
+ }
+ }
+ } else {
+ interrupt := new(atomic.Int32)
+ timer := time.AfterFunc(miner.config.Recommit, func() {
+ interrupt.Store(commitInterruptTimeout)
+ })
+ defer timer.Stop()
- err := miner.fillTransactions(interrupt, work)
- if errors.Is(err, errBlockInterruptedByTimeout) {
- log.Warn("Block building is interrupted", "allowance", common.PrettyDuration(miner.config.Recommit))
+ err := miner.fillTransactions(interrupt, work)
+ if errors.Is(err, errBlockInterruptedByTimeout) {
+ log.Warn("Block building is interrupted", "allowance", common.PrettyDuration(miner.config.Recommit))
+ }
}
}
body := types.Body{Transactions: work.txs, Withdrawals: genParam.withdrawals}
@@ -224,6 +243,9 @@ func (miner *Miner) prepareWork(genParams *generateParams, witness bool) (*envir
if len(miner.config.ExtraData) != 0 {
header.Extra = miner.config.ExtraData
}
+ if genParams.forceOverrides {
+ header.Extra = genParams.overrideExtraData
+ }
// Set the randomness field from the beacon chain if it's available.
if genParams.random != (common.Hash{}) {
header.MixDigest = genParams.random