diff --git a/src/Nethermind/Nethermind.Benchmark/Store/PatriciaTrieWitnessGeneratorBenchmarks.cs b/src/Nethermind/Nethermind.Benchmark/Store/PatriciaTrieWitnessGeneratorBenchmarks.cs
new file mode 100644
index 000000000000..f752f1fb5cb1
--- /dev/null
+++ b/src/Nethermind/Nethermind.Benchmark/Store/PatriciaTrieWitnessGeneratorBenchmarks.cs
@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using BenchmarkDotNet.Attributes;
+using Nethermind.Core;
+using Nethermind.Core.Collections;
+using Nethermind.Core.Crypto;
+using Nethermind.Db;
+using Nethermind.Logging;
+using Nethermind.Trie;
+using Nethermind.Trie.Pruning;
+
+namespace Nethermind.Benchmarks.Store
+{
+ ///
+ /// Compares the new mutation-free (sequential and parallel) against
+ /// the old "capture trie reads during the actual mutation" technique it replaces.
+ ///
+ [MemoryDiagnoser]
+ [MinIterationTime(1000)]
+ public class PatriciaTrieWitnessGeneratorBenchmarks
+ {
+ [Params(100_000)]
+ public int TrieSize { get; set; }
+
+ [Params(1_000, 5_000)]
+ public int TouchedCount { get; set; }
+
+ [Params(0.0, 0.5)]
+ public double DeleteFraction { get; set; }
+
+ private MemDb _db;
+ private Hash256 _root;
+ private PatriciaTrieWitnessGenerator.PathEntry[] _entries;
+ private Hash256[] _reads;
+ private Hash256[] _deletes;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ Random rng = new(0);
+
+ MemDb db = new();
+ RawScopedTrieStore store = new(db);
+ PatriciaTree tree = new(store, LimboLogs.Instance);
+
+ Hash256[] keys = new Hash256[TrieSize];
+ using ArrayPoolListRef bulk = new(TrieSize);
+ for (int i = 0; i < TrieSize; i++)
+ {
+ byte[] keyBuf = new byte[32];
+ rng.NextBytes(keyBuf);
+ byte[] valueBuf = new byte[32];
+ rng.NextBytes(valueBuf);
+ keys[i] = new Hash256(keyBuf);
+ bulk.Add(new PatriciaTree.BulkSetEntry(keys[i], valueBuf));
+ }
+ tree.BulkSet(bulk);
+ tree.Commit();
+
+ _db = db;
+ _root = tree.RootHash;
+
+ int deleteCount = (int)(TouchedCount * DeleteFraction);
+ _entries = new PatriciaTrieWitnessGenerator.PathEntry[TouchedCount];
+ List reads = [];
+ List deletes = [];
+ for (int i = 0; i < TouchedCount; i++)
+ {
+ Hash256 key = keys[rng.Next(TrieSize)];
+ bool isDeleted = i < deleteCount;
+ _entries[i] = new PatriciaTrieWitnessGenerator.PathEntry(
+ key,
+ isDeleted ? PatriciaTrieWitnessGenerator.AccessType.Delete : PatriciaTrieWitnessGenerator.AccessType.Read);
+ (isDeleted ? deletes : reads).Add(key);
+ }
+
+ _reads = [.. reads];
+ _deletes = [.. deletes];
+ }
+
+ [Benchmark(Baseline = true)]
+ public int Old_CaptureDuringMutation()
+ {
+ CapturingScopedTrieStore store = new(new RawScopedTrieStore(_db));
+ PatriciaTree tree = new(store, LimboLogs.Instance) { RootHash = _root };
+ foreach (Hash256 key in _reads) tree.Get(key.Bytes);
+ foreach (Hash256 key in _deletes) tree.Set(key.Bytes, (byte[])null);
+ tree.UpdateRootHash();
+ return store.Captured.Count;
+ }
+
+ [Benchmark]
+ public int New_Sequential()
+ {
+ CountingSink sink = new();
+ PatriciaTrieWitnessGenerator.Generate(new RawScopedTrieStore(_db), _root, _entries, sink, parallelize: false);
+ return sink.Count;
+ }
+
+ [Benchmark]
+ public int New_Parallel()
+ {
+ CountingSink sink = new();
+ PatriciaTrieWitnessGenerator.Generate(new RawScopedTrieStore(_db), _root, _entries, sink, parallelize: true);
+ return sink.Count;
+ }
+
+ private sealed class CountingSink : PatriciaTrieWitnessGenerator.ISink
+ {
+ private int _count;
+ public int Count => _count;
+ public void Add(in TreePath path, TrieNode node) => Interlocked.Increment(ref _count);
+ }
+
+ private sealed class CapturingScopedTrieStore(IScopedTrieStore baseStore) : IScopedTrieStore
+ {
+ public Dictionary Captured { get; } = [];
+
+ public TrieNode FindCachedOrUnknown(in TreePath path, Hash256 hash) => baseStore.FindCachedOrUnknown(in path, hash);
+
+ public byte[] LoadRlp(in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) => Capture(hash, baseStore.LoadRlp(in path, hash, flags));
+
+ public byte[] TryLoadRlp(in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) => Capture(hash, baseStore.TryLoadRlp(in path, hash, flags));
+
+ private byte[] Capture(Hash256 hash, byte[] rlp)
+ {
+ if (rlp is not null) Captured[hash] = rlp;
+ return rlp;
+ }
+
+ public ITrieNodeResolver GetStorageTrieNodeResolver(Hash256 address) => baseStore.GetStorageTrieNodeResolver(address);
+
+ public INodeStorage.KeyScheme Scheme => baseStore.Scheme;
+
+ public ICommitter BeginCommit(TrieNode root, WriteFlags writeFlags = WriteFlags.None) => baseStore.BeginCommit(root, writeFlags);
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.Consensus/Processing/BlockAccessListManager.cs b/src/Nethermind/Nethermind.Consensus/Processing/BlockAccessListManager.cs
index 8bdcea2ec21e..6a28d581a9d4 100644
--- a/src/Nethermind/Nethermind.Consensus/Processing/BlockAccessListManager.cs
+++ b/src/Nethermind/Nethermind.Consensus/Processing/BlockAccessListManager.cs
@@ -124,11 +124,17 @@ public void PrepareForProcessing(Block suggestedBlock, IReleaseSpec spec, Proces
// Parallel execution needs the decoded BAL body (RLP fixtures only carry the hash)
// and an active state scope (so we can capture the parent state root for workers).
+ //
+ // Witness-mode managers force sequential: parallel workers read pre-state through pooled
+ // parent-reader snapshots that bypass the recording world state, so their accesses would be
+ // missing from the witness. Only the witness-processing graph sets witnessMode, so regular
+ // blocks on EIP-7928 chains keep parallel execution.
ParallelExecutionEnabled = Enabled
&& blocksConfig.ParallelExecution
&& !_isBuilding
&& suggestedBlock.BlockAccessList is not null
- && stateProvider.IsInScope;
+ && stateProvider.IsInScope
+ && !witnessMode;
// BAL-driven read warming: mirrors BlockCachePreWarmer.IsBalReadWarmingEnabled so
// HintBal honours the same opt-in config as the prewarmer path.
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/MultiAccountProofCollector.cs b/src/Nethermind/Nethermind.Consensus/Stateless/MultiAccountProofCollector.cs
deleted file mode 100644
index 9eb5a4f8023f..000000000000
--- a/src/Nethermind/Nethermind.Consensus/Stateless/MultiAccountProofCollector.cs
+++ /dev/null
@@ -1,117 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
-// SPDX-License-Identifier: LGPL-3.0-only
-
-using System;
-using System.Collections.Generic;
-using Nethermind.Core;
-using Nethermind.Core.Crypto;
-using Nethermind.Int256;
-using Nethermind.Trie;
-
-namespace Nethermind.Consensus.Stateless;
-
-///
-/// Walks the trie once and captures trie-node RLP along the path to each of N target accounts and,
-/// by descending into each touched account's storage trie at its leaf, along the path to each of
-/// that account's touched slots. Storage-trie nodes are discriminated per account via
-/// ctx.Storage, which carries the owning account's path commitment (keccak(address)).
-///
-internal sealed class MultiAccountProofCollector : ITreeVisitor
-{
- private readonly ValueHash256[] _accountHashes;
- // Sorted keccak(slot) targets per account, keyed by keccak(address) (= ctx.Storage at the descent).
- private readonly Dictionary _slotHashesByAccount;
- private readonly List _nodes;
-
- public IReadOnlyList Nodes => _nodes;
-
- public MultiAccountProofCollector(IReadOnlyDictionary> storageSlots)
- {
- int n = storageSlots.Count;
- _accountHashes = new ValueHash256[n];
- _slotHashesByAccount = new Dictionary(n, GenericEqualityComparer.GetOptimized());
- int i = 0;
- int totalSlots = 0;
- Span slotKey = stackalloc byte[32];
- foreach (KeyValuePair> entry in storageSlots)
- {
- ValueHash256 accountHash = ValueKeccak.Compute(entry.Key.Value.Bytes);
- _accountHashes[i++] = accountHash;
-
- if (entry.Value.Count == 0) continue;
- totalSlots += entry.Value.Count;
- ValueHash256[] slotHashes = new ValueHash256[entry.Value.Count];
- int j = 0;
- foreach (UInt256 slot in entry.Value)
- {
- slot.ToBigEndian(slotKey);
- slotHashes[j++] = ValueKeccak.Compute(slotKey);
- }
- Array.Sort(slotHashes);
- _slotHashesByAccount[accountHash] = slotHashes;
- }
-
- // Sorted so ShouldVisit can binary search instead of scanning every hash. The scan is
- // O(targets) per visited child, which dominates block-scale walks (thousands of accounts).
- Array.Sort(_accountHashes);
-
- // Capacity hint: one trie path of typical depth per touched account and slot.
- _nodes = new List(Math.Max(16, n * 8 + totalSlots * 4));
- }
-
- public bool IsFullDbScan => false;
-
- public bool ShouldVisit(in TreePathContextWithStorage ctx, in ValueHash256 nextNode)
- {
- // Inside a storage trie, filter by the owning account's touched slots. The descent itself
- // is gated on the account leaf's full path matching a target, so an untouched account's
- // storage is never entered.
- if (ctx.Storage is not null)
- {
- return _slotHashesByAccount.TryGetValue(ctx.Storage, out ValueHash256[]? slotHashes)
- && HasTargetWithPrefix(slotHashes, ctx.Path);
- }
-
- return HasTargetWithPrefix(_accountHashes, ctx.Path);
- }
-
- private static bool HasTargetWithPrefix(ValueHash256[] sortedHashes, in TreePath path)
- {
- // Hashes with the path as nibble-prefix form one contiguous run in the sorted array, and
- // the run starts at the first hash >= the zero-padded path (any later non-member exceeds
- // the path in a prefix nibble) — so the lower-bound element alone decides membership.
- int index = Array.BinarySearch(sortedHashes, path.ToLowerBoundPath());
- if (index < 0) index = ~index;
- return index < sortedHashes.Length && IsPrefix(sortedHashes[index].Bytes, path);
- }
-
- public void VisitTree(in TreePathContextWithStorage ctx, in ValueHash256 rootHash) { }
-
- public void VisitMissingNode(in TreePathContextWithStorage ctx, in ValueHash256 nodeHash) { }
-
- public void VisitBranch(in TreePathContextWithStorage ctx, TrieNode node) => AddProofItem(node);
-
- public void VisitExtension(in TreePathContextWithStorage ctx, TrieNode node) => AddProofItem(node);
-
- public void VisitLeaf(in TreePathContextWithStorage ctx, TrieNode node) => AddProofItem(node);
-
- public void VisitAccount(in TreePathContextWithStorage ctx, TrieNode node, in AccountStruct account) { }
-
- private void AddProofItem(TrieNode node)
- {
- if (node.Keccak is null) return;
- _nodes.Add(node.FullRlp.ToArray());
- }
-
- private static bool IsPrefix(ReadOnlySpan target, in TreePath currentPath)
- {
- int length = currentPath.Length;
- if (length > target.Length * 2) return false;
- for (int i = 0; i < length; i++)
- {
- int targetNibble = (i & 1) == 0 ? target[i >> 1] >> 4 : target[i >> 1] & 0x0F;
- if (currentPath[i] != targetNibble) return false;
- }
- return true;
- }
-}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/StatelessBlockProcessingEnv.cs b/src/Nethermind/Nethermind.Consensus/Stateless/StatelessBlockProcessingEnv.cs
index 96439cd003ac..efa8f5c353ab 100644
--- a/src/Nethermind/Nethermind.Consensus/Stateless/StatelessBlockProcessingEnv.cs
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/StatelessBlockProcessingEnv.cs
@@ -60,6 +60,8 @@ private BlockProcessor GetProcessor()
ParallelExecutionBatchRead = false
},
new WithdrawalProcessorFactory(logManager),
+ // Stateless execution must resolve code only from the witness-backed state, never the
+ // shared cache — so it always runs in witness mode (non-caching code reads).
witnessMode: true
);
BlockProcessor.ParallelBlockValidationTransactionsExecutor txExecutor = new(
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessingEnv.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessingEnv.cs
new file mode 100644
index 000000000000..60d38b4924f1
--- /dev/null
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessingEnv.cs
@@ -0,0 +1,146 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using Autofac;
+using Nethermind.Blockchain;
+using Nethermind.Blockchain.Headers;
+using Nethermind.Config;
+using Nethermind.Consensus.Processing;
+using Nethermind.Consensus.Withdrawals;
+using Nethermind.Core;
+using Nethermind.Core.Container;
+using Nethermind.Core.Specs;
+using Nethermind.Evm;
+using Nethermind.Evm.State;
+using Nethermind.Logging;
+using Nethermind.State;
+using Nethermind.Trie.Pruning;
+
+namespace Nethermind.Consensus.Stateless;
+
+///
+/// Owns the second, statically witness-wired graph that the
+/// selector delegates a witnessed block to. The graph
+/// shares the main pipeline's writable through a transparent
+/// recorder, so processing through it is the real block
+/// import — recorded as a side effect rather than re-executed.
+///
+///
+///
+/// The graph is built off the root lifetime scope (never the main-processing child scope) so
+/// it does not inherit that scope's selector decorator — building off the
+/// main scope would make the witness processor resolve back through the selector and form a cycle. The
+/// recorder still wraps the exact main-pipeline instance (taken from
+/// ) so the 's
+/// scope/commit on that instance and the witness execution stay coherent.
+///
+///
+/// Construction is deferred to the first witnessed block: nodes on an EIP-7928 chain that never receive
+/// an engine_newPayloadWithWitness request never pay for a second processing graph. The selector
+/// drives blocks serially on the processing loop, so the recorder is reused across blocks — cleared via
+/// before each capture.
+///
+///
+public sealed class WitnessCapturingBlockProcessingEnv(
+ ILifetimeScope rootLifetimeScope,
+ IWorldStateManager worldStateManager,
+ IHeaderStore headerStore,
+ IBlockValidationModule[] validationModules) : IDisposable
+{
+ private readonly Lazy _graph = new(() =>
+ Build(rootLifetimeScope, worldStateManager, headerStore, validationModules));
+
+ /// The witness-wired block processor; the same instance is reused for every witnessed block.
+ public IBlockProcessor Processor => _graph.Value.Processor;
+
+ /// Clears the recorder accumulators so the next witnessed block starts from a clean slate.
+ public void ResetForBlock()
+ {
+ Graph graph = _graph.Value;
+ graph.Recorder.Reset();
+ graph.HeaderRecorder.Reset();
+ graph.BlockhashCache.Clear();
+ }
+
+ /// Projects the accesses recorded during the last run into a witness.
+ public Witness GetWitness(BlockHeader parent) => _graph.Value.Recorder.GetWitness(parent);
+
+ public void Dispose()
+ {
+ if (_graph.IsValueCreated) _graph.Value.Dispose();
+ }
+
+ private static Graph Build(
+ ILifetimeScope rootLifetimeScope,
+ IWorldStateManager worldStateManager,
+ IHeaderStore headerStore,
+ IBlockValidationModule[] validationModules)
+ {
+ // The exact main-pipeline world state the BranchProcessor scopes/commits; the recorder wraps it.
+ IWorldState parentWorldState = rootLifetimeScope.Resolve().WorldState;
+
+ // Read-only trie store for the post-execution witness walk at the parent state root.
+ IReadOnlyTrieStore trieStore = worldStateManager.CreateReadOnlyTrieStore();
+ WitnessHeaderRecorder headerRecorder = new();
+ WitnessGeneratingWorldState recorder = new(
+ parentWorldState,
+ worldStateManager.GlobalStateReader,
+ trieStore,
+ headerRecorder,
+ headerStore);
+ WitnessCapturingHeaderFinder recordingFinder = new(headerStore, headerRecorder);
+
+ ILifetimeScope scope = rootLifetimeScope.BeginLifetimeScope(builder => builder
+ // Recorder over the shared writable state — registered by instance, so the decorator wraps
+ // the captured parent instance rather than re-resolving itself (no cycle).
+ .AddScoped(recorder)
+ // Recording header finder + scoped blockhash cache so BLOCKHASH header reads are captured.
+ .AddScoped(recordingFinder)
+ .AddScoped()
+ // Non-caching code repo so every bytecode/code-hash lookup flows through the recorder.
+ .AddScoped()
+ // Witness-mode BAL: statically sequential + non-caching, no parallel parent-reader pool that
+ // would read pre-state outside the recorder.
+ .AddScoped(ctx => new BlockAccessListManager(
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ witnessMode: true))
+ // The validation transaction executor; everything else (BlockProcessor, validators, beacon
+ // root/blockhash/withdrawal/exec-requests processors, VM, tx processor) is inherited from the
+ // root registrations and re-resolved here against the overridden world state.
+ .AddModule(validationModules));
+
+ IBlockProcessor processor = scope.Resolve();
+ IBlockhashCache blockhashCache = scope.Resolve();
+ return new Graph(scope, trieStore, recorder, headerRecorder, processor, blockhashCache);
+ }
+
+ ///
+ /// The witness bundle plus the resources whose lifetime it owns: the witness scope and the
+ /// externally-owned witness-walk trie store, both disposed when the env is disposed.
+ ///
+ private sealed class Graph(
+ ILifetimeScope scope,
+ IReadOnlyTrieStore trieStore,
+ WitnessGeneratingWorldState recorder,
+ WitnessHeaderRecorder headerRecorder,
+ IBlockProcessor processor,
+ IBlockhashCache blockhashCache) : IDisposable
+ {
+ public WitnessGeneratingWorldState Recorder => recorder;
+ public WitnessHeaderRecorder HeaderRecorder => headerRecorder;
+ public IBlockProcessor Processor => processor;
+ public IBlockhashCache BlockhashCache => blockhashCache;
+
+ public void Dispose()
+ {
+ try { scope.Dispose(); }
+ finally { trieStore.Dispose(); }
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessor.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessor.cs
new file mode 100644
index 000000000000..aaa959c0e7d9
--- /dev/null
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingBlockProcessor.cs
@@ -0,0 +1,95 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Nethermind.Blockchain.Headers;
+using Nethermind.Consensus.Processing;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Specs;
+using Nethermind.Evm.Tracing;
+using Nethermind.Logging;
+
+namespace Nethermind.Consensus.Stateless;
+
+///
+/// decorator on the main pipeline that, when a witness has been requested
+/// for the block being processed, routes that single to the dedicated
+/// witness-wired processor () instead of the main inner
+/// processor, then projects the recorded accesses into a and publishes it via
+/// . Every other block flows straight through to the inner processor.
+///
+///
+/// The witness processor shares the main pipeline's writable world state (through a transparent
+/// recorder), so the witnessed block is really imported — it is not re-executed. Selection happens once
+/// per block at this single point; the witness processor's world state, code repository and block-access
+/// -list manager are statically witness-configured, so there are no per-call predicates anywhere below.
+///
+public sealed class WitnessCapturingBlockProcessor(
+ IBlockProcessor inner,
+ WitnessCapturingBlockProcessingEnv witness,
+ WitnessRendezvous rendezvous,
+ IHeaderFinder headerFinder,
+ ILogManager? logManager = null) : IBlockProcessor
+{
+ private readonly ILogger _logger = (logManager ?? LimboLogs.Instance).GetClassLogger();
+
+ public event Action? TransactionsExecuted
+ {
+ add => inner.TransactionsExecuted += value;
+ remove => inner.TransactionsExecuted -= value;
+ }
+
+ public (Block Block, TxReceipt[] Receipts) ProcessOne(
+ Block suggestedBlock,
+ ProcessingOptions options,
+ IBlockTracer blockTracer,
+ IReleaseSpec spec,
+ CancellationToken token = default)
+ {
+ Hash256? blockHash = suggestedBlock.Hash;
+ Hash256? parentHash = suggestedBlock.ParentHash;
+
+ bool shouldCapture =
+ blockHash is not null
+ && parentHash is not null
+ && !options.ContainsFlag(ProcessingOptions.ReadOnlyChain)
+ && rendezvous.HasPendingRequest(blockHash);
+
+ if (!shouldCapture)
+ return inner.ProcessOne(suggestedBlock, options, blockTracer, spec, token);
+
+ long parentBlockNumber = suggestedBlock.Number - 1;
+ BlockHeader parent = headerFinder.Get(parentHash, parentBlockNumber)
+ ?? throw new ArgumentException($"Unable to find parent for block {parentBlockNumber} with hash {parentHash}");
+
+ witness.ResetForBlock();
+
+ try
+ {
+ (Block Block, TxReceipt[] Receipts) result = witness.Processor.ProcessOne(suggestedBlock, options, blockTracer, spec, token);
+
+ if (!rendezvous.TryClaim(blockHash!, out TaskCompletionSource? tcs))
+ return result; // request was cancelled while we were processing — nothing to publish.
+
+ Witness? capturedWitness = null;
+ try
+ {
+ capturedWitness = witness.GetWitness(parent);
+ }
+ catch (Exception ex)
+ {
+ if (_logger.IsError) _logger.Error($"{nameof(WitnessCapturingBlockProcessor)}: witness build failed for block {blockHash}", ex);
+ }
+ tcs!.SetResult(capturedWitness);
+ return result;
+ }
+ catch
+ {
+ rendezvous.CancelWitnessRequest(blockHash!);
+ throw;
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingHeaderFinder.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingHeaderFinder.cs
new file mode 100644
index 000000000000..cc05aa45fd08
--- /dev/null
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingHeaderFinder.cs
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using Nethermind.Blockchain.Headers;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+
+namespace Nethermind.Consensus.Stateless;
+
+///
+/// Transparent decorator that side-channels every successful header lookup
+/// into a , so BLOCKHASH lookups during EVM execution extend the
+/// witness header chain back to whatever the EVM touched.
+///
+///
+/// Installed only inside the dedicated witness processing graph, so it records unconditionally — there
+/// is no armed/disarmed state to consult.
+///
+public sealed class WitnessCapturingHeaderFinder(IHeaderFinder inner, WitnessHeaderRecorder recorder) : IHeaderFinder
+{
+ public BlockHeader? Get(Hash256 blockHash, long? blockNumber = null)
+ {
+ BlockHeader? header = inner.Get(blockHash, blockNumber);
+ if (header is not null) recorder.OnHeaderRead(header);
+ return header;
+ }
+}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingMainProcessingModule.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingMainProcessingModule.cs
new file mode 100644
index 000000000000..cede283fd66c
--- /dev/null
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingMainProcessingModule.cs
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using Autofac;
+using Nethermind.Consensus.Processing;
+using Nethermind.Core;
+using Nethermind.Core.Container;
+using Nethermind.Core.Specs;
+
+namespace Nethermind.Consensus.Stateless;
+
+///
+/// On EIP-7928 chains, installs the selector on the main
+/// processing pipeline. The selector delegates a witnessed block to the dedicated witness graph held by
+/// (registered at the root scope by the merge plugin), leaving the
+/// main world state, code repository and block-access-list manager untouched for every other block.
+///
+public sealed class WitnessCapturingMainProcessingModule(ISpecProvider specProvider) : Module, IMainProcessingModule
+{
+ protected override void Load(ContainerBuilder builder)
+ {
+ if (!specProvider.GetFinalSpec().IsEip7928Enabled) return;
+
+ builder.AddDecorator();
+ }
+}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingTrieStore.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingTrieStore.cs
deleted file mode 100644
index 8c4544643a6d..000000000000
--- a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessCapturingTrieStore.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
-// SPDX-License-Identifier: LGPL-3.0-only
-
-using System;
-using System.Collections.Generic;
-using System.Runtime.InteropServices;
-using Nethermind.Core;
-using Nethermind.Core.Crypto;
-using Nethermind.Trie;
-using Nethermind.Trie.Pruning;
-
-namespace Nethermind.Consensus.Stateless;
-
-///
-/// Delegates all logic to base store except for writing trie nodes (readonly!)
-/// Adds logic for capturing trie nodes accessed during execution and state root recomputation.
-///
-public class WitnessCapturingTrieStore(IReadOnlyTrieStore baseStore) : ITrieStore
-{
- // Plain Dictionary, not ConcurrentDictionary: a rented entry is exclusive to a single synchronous
- // caller, so the collector only ever sees one writer per rent.
- private readonly Dictionary _rlpCollector = [];
-
- public IEnumerable TouchedNodesRlp => _rlpCollector.Values;
-
- /// Clears the captured-node set so the wrapper can be reused across pooled rents.
- public void Reset() => _rlpCollector.Clear();
-
- public void Dispose() => baseStore.Dispose();
-
- public TrieNode FindCachedOrUnknown(Hash256? address, in TreePath path, Hash256 hash)
- {
- TrieNode node = baseStore.FindCachedOrUnknown(address, in path, hash);
- if (node.NodeType != NodeType.Unknown)
- {
- // Materialise the RLP only on first capture: TryAdd would allocate node.FullRlp.ToArray()
- // on every cache hit (hot in SLOAD loops touching the same branch) just to discard it.
- ref byte[]? slot = ref CollectionsMarshal.GetValueRefOrAddDefault(_rlpCollector, node.Keccak, out bool exists);
- if (!exists) slot = node.FullRlp.ToArray();
- }
- return node;
- }
-
- public byte[]? LoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) =>
- TryLoadRlp(address, in path, hash, flags)
- ?? throw new MissingTrieNodeException("Missing RLP node", address, path, hash);
-
- public byte[]? TryLoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None)
- {
- byte[]? rlp = baseStore.TryLoadRlp(address, in path, hash, flags);
- if (rlp is not null) _rlpCollector.TryAdd(hash, rlp);
- return rlp;
- }
-
- public bool HasRoot(Hash256 stateRoot) => baseStore.HasRoot(stateRoot);
-
- public IDisposable BeginScope(BlockHeader? baseBlock) => baseStore.BeginScope(baseBlock);
-
- public IScopedTrieStore GetTrieStore(Hash256? address) => new ScopedTrieStore(this, address);
-
- public INodeStorage.KeyScheme Scheme => baseStore.Scheme;
-
- public IBlockCommitter BeginBlockCommit(long blockNumber) => NullCommitter.Instance;
-
- // WitnessCapturingTrieStore is read-only, so we return a no-op committer that doesn't persist any trie nodes
- public ICommitter BeginCommit(Hash256? address, TrieNode? root, WriteFlags writeFlags) => NullCommitter.Instance;
-}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingBlockProcessingEnvFactory.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingBlockProcessingEnvFactory.cs
index 2db71ad18ede..9e18a40b25eb 100644
--- a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingBlockProcessingEnvFactory.cs
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingBlockProcessingEnvFactory.cs
@@ -8,13 +8,18 @@
using Nethermind.Blockchain;
using Nethermind.Blockchain.Headers;
using Nethermind.Blockchain.Receipts;
+using Nethermind.Config;
+using Nethermind.Consensus.Processing;
+using Nethermind.Consensus.Withdrawals;
using Nethermind.Core;
using Nethermind.Core.Container;
+using Nethermind.Core.Specs;
using Nethermind.Db;
using Nethermind.Evm;
using Nethermind.Evm.State;
using Nethermind.Logging;
using Nethermind.State;
+using Nethermind.Trie.Pruning;
namespace Nethermind.Consensus.Stateless;
@@ -32,11 +37,11 @@ public interface IWitnessGeneratingBlockProcessingEnvFactory
/// Builds a on demand and pools entries for reuse.
///
///
-/// Each rent returns a fully-wired env (own WorldState stack, trie-store wrapper, header finder, Autofac
-/// child scope). The first rent on an empty pool pays full construction cost; subsequent rents reuse a
-/// pooled entry. Entries are reset on return (so a pooled entry never pins its last call's witness
-/// buffers) and the pool is soft-capped — surplus and poisoned entries are disposed rather than pooled.
-/// Disposing the factory drains the pool.
+/// Each rent returns a fully-wired env (own WorldState stack, capturing trie-store wrapper, header
+/// finder, Autofac child scope). The first rent on an
+/// empty pool pays full construction cost; subsequent rents reuse a pooled entry. Entries are reset on
+/// return (so a pooled entry never pins its last call's witness buffers) and the pool is soft-capped —
+/// surplus and poisoned entries are disposed rather than pooled. Disposing the factory drains the pool.
///
public class WitnessGeneratingBlockProcessingEnvFactory(
ILifetimeScope rootLifetimeScope,
@@ -72,31 +77,48 @@ public IWitnessGeneratingBlockProcessingEnvScope CreateScope()
private PooledEntry BuildEntry()
{
IReadOnlyDbProvider readOnlyDbProvider = new ReadOnlyDbProvider(dbProvider, true);
- WitnessCapturingTrieStore trieStore = new(worldStateManager.CreateReadOnlyTrieStore());
+
+ // Per-entry session + recorders. The session is armed once for the entry's lifetime (the env's
+ // components are wired directly, not via the main-pipeline proxy); Reset() clears the recorder
+ // data between rents while leaving the session armed at the same recorder instances.
+ WitnessHeaderRecorder headerRecorder = new();
+
+ IReadOnlyTrieStore trieStore = worldStateManager.CreateReadOnlyTrieStore();
IStateReader stateReader = new StateReader(trieStore, readOnlyDbProvider.CodeDb, logManager);
IWorldState baseWorldState = new WorldState(
new TrieStoreScopeProvider(trieStore, readOnlyDbProvider.CodeDb, logManager), logManager);
IHeaderStore headerStore = rootLifetimeScope.Resolve();
- WitnessGeneratingHeaderFinder headerFinder = new(headerStore);
- // Proof-collection walks go through the global (non-capturing) reader; the capturing
- // stateReader serves execution-path reads.
- WitnessGeneratingWorldState witnessWorldState = new(baseWorldState, worldStateManager.GlobalStateReader, trieStore, headerFinder);
+ WitnessCapturingHeaderFinder capturingHeaderFinder = new(headerStore, headerRecorder);
+ // Proof-collection walks go through the global (non-capturing) reader; the capturing trieStore
+ // serves execution-path reads (not account proof collection). headerStore is the undecorated source BuildHeaders walks.
+ WitnessGeneratingWorldState witnessWorldState = new(
+ baseWorldState, worldStateManager.GlobalStateReader, trieStore, headerRecorder, headerStore);
ILifetimeScope envLifetimeScope = rootLifetimeScope.BeginLifetimeScope(builder => builder
.AddScoped(stateReader)
.AddScoped(witnessWorldState)
.AddScoped(witnessWorldState)
- .AddScoped(headerFinder)
+ .AddScoped(capturingHeaderFinder)
.AddScoped()
.AddScoped(NullReceiptStorage.Instance)
.AddScoped()
+ // The whole sandbox re-execution records a witness, so its BlockAccessListManager runs in
+ // witness mode unconditionally (sequential + non-caching code reads).
+ .AddScoped(ctx => new BlockAccessListManager(
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ ctx.Resolve(),
+ witnessMode: true))
.AddModule(validationModules)
.AddScoped());
IWitnessGeneratingBlockProcessingEnv env = envLifetimeScope.Resolve();
IBlockhashCache blockhashCache = envLifetimeScope.Resolve();
- return new PooledEntry(envLifetimeScope, readOnlyDbProvider, trieStore, witnessWorldState, headerFinder, blockhashCache, env);
+ return new PooledEntry(envLifetimeScope, trieStore, readOnlyDbProvider, headerRecorder, witnessWorldState, blockhashCache, env);
}
private void Return(PooledEntry entry)
@@ -106,7 +128,7 @@ private void Return(PooledEntry entry)
// tolerated when threads race past the check; root-scope disposal still reclaims any straggler.
if (_disposed || Volatile.Read(ref _poolCount) >= MaxPoolSize)
{
- entry.Scope.Dispose();
+ entry.Dispose();
return;
}
@@ -118,7 +140,7 @@ private void Return(PooledEntry entry)
}
catch
{
- entry.Scope.Dispose();
+ entry.Dispose();
return;
}
@@ -132,7 +154,7 @@ private void Return(PooledEntry entry)
while (_pool.TryPop(out PooledEntry? stale))
{
Interlocked.Decrement(ref _poolCount);
- stale.Scope.Dispose();
+ stale.Dispose();
}
}
}
@@ -143,33 +165,41 @@ public void Dispose()
while (_pool.TryPop(out PooledEntry? entry))
{
Interlocked.Decrement(ref _poolCount);
- entry.Scope.Dispose();
+ entry.Dispose();
}
}
private sealed class PooledEntry(
ILifetimeScope scope,
+ IReadOnlyTrieStore trieStore,
IReadOnlyDbProvider dbProvider,
- WitnessCapturingTrieStore trieStore,
+ WitnessHeaderRecorder headerRecorder,
WitnessGeneratingWorldState worldState,
- WitnessGeneratingHeaderFinder headerFinder,
IBlockhashCache blockhashCache,
- IWitnessGeneratingBlockProcessingEnv env)
+ IWitnessGeneratingBlockProcessingEnv env) : IDisposable
{
public ILifetimeScope Scope { get; } = scope;
public IWitnessGeneratingBlockProcessingEnv Env { get; } = env;
+ /// Tears down the entry: the Autofac scope (and everything it owns) first, then the
+ /// manually-created read-only trie store the scope's components borrowed.
+ public void Dispose()
+ {
+ Scope.Dispose();
+ trieStore.Dispose();
+ }
+
/// Wipes per-call accumulators so the entry is safe for the next rent.
///
/// The inner WorldState's per-call caches are already cleared by WorldState.BeginScope's
/// scope-exit Reset(true); only the witness-specific accumulators are cleared here. The
+ /// session stays armed at the same recorder instances — clearing the recorders is enough. The
/// blockhash cache is content-addressed (never stale) but grows per entry, so it's cleared too.
///
public void Reset()
{
- trieStore.Reset();
+ headerRecorder.Reset();
worldState.Reset();
- headerFinder.Reset();
dbProvider.ClearTempChanges();
blockhashCache.Clear();
}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingWorldState.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingWorldState.cs
index f33014f39984..3f35a57f2893 100644
--- a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingWorldState.cs
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingWorldState.cs
@@ -3,13 +3,11 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Runtime.InteropServices;
-using Collections.Pooled;
+using Nethermind.Blockchain.Headers;
using Nethermind.Core;
using Nethermind.Core.Collections;
using Nethermind.Core.Crypto;
-using Nethermind.Core.Extensions;
using Nethermind.Core.Specs;
using Nethermind.Evm.State;
using Nethermind.Int256;
@@ -19,14 +17,15 @@
namespace Nethermind.Consensus.Stateless;
-/// Serves the post-execution proof-collection walks in ;
-/// must be a plain (non-capturing) reader — re-traversal is proof collection, not state access, so
-/// recording it into would only duplicate the witness buffers.
+/// Plain (non-capturing) reader for pre-state accounts (their storage roots) at
+/// the parent block, used to drive the post-execution witness walk in .
+/// Read-only trie store walked at the pre-state root to collect the witness nodes.
public class WitnessGeneratingWorldState(
IWorldState state,
IStateReader stateReader,
- WitnessCapturingTrieStore trieStore,
- WitnessGeneratingHeaderFinder headerFinder)
+ IReadOnlyTrieStore trieStore,
+ WitnessHeaderRecorder headerRecorder,
+ IHeaderFinder headerFinder)
: WorldStateDecorator(state)
{
private readonly Dictionary> _storageSlots = [];
@@ -42,32 +41,8 @@ public void Reset()
public Witness GetWitness(BlockHeader parentHeader)
{
- // A reverted write leaves no trie traversal (the write was cached, then discarded), so its
- // trie nodes were never captured. The walk below re-traverses the touched keys to capture
- // them — only needed for cross-client (e.g. geth) stateless re-execution; our own execution
- // wouldn't require it.
- if (!trieStore.TouchedNodesRlp.Any())
- {
- // No storage-slot or account reads — lazy TrieNode handling can leave the root node
- // uncaptured. Resolve it explicitly so the witness still includes it.
- ITrieNodeResolver stateResolver = trieStore.GetTrieStore(null);
- TreePath path = TreePath.Empty;
- TrieNode node = stateResolver.FindCachedOrUnknown(path, parentHeader.StateRoot!);
- node.ResolveNode(stateResolver, path);
- }
-
- using PooledSet stateNodes = new(trieStore.TouchedNodesRlp, Bytes.EqualityComparer);
- if (_storageSlots.Count > 0)
- {
- // A single walk captures both the state-trie path to every touched account and, via the
- // storage descent at each account leaf, the storage-trie path to every touched slot.
- MultiAccountProofCollector collector = new(_storageSlots);
- stateReader.RunTreeVisitor(collector, parentHeader);
- foreach (byte[] node in collector.Nodes)
- {
- stateNodes.Add(node);
- }
- }
+ CollectingSink sink = new();
+ CollectStateNodes(parentHeader, sink);
// New pool-rented buffers added here must also be disposed in the catch below.
ArrayPoolList? codes = null;
@@ -79,8 +54,8 @@ public Witness GetWitness(BlockHeader parentHeader)
foreach (byte[] code in _bytecodes.Values)
codes.Add(code);
- state = new ArrayPoolList(stateNodes.Count);
- foreach (byte[] node in stateNodes)
+ state = new ArrayPoolList(sink.Nodes.Count);
+ foreach (byte[] node in sink.Nodes.Values)
state.Add(node);
int totalKeysCount = _storageSlots.Count;
@@ -103,13 +78,13 @@ public Witness GetWitness(BlockHeader parentHeader)
Codes = codes,
State = state,
Keys = keys,
- Headers = headerFinder.GetWitnessHeaders(parentHeader.Hash!)
+ Headers = headerRecorder.BuildHeaders(parentHeader.Hash!, headerFinder)
};
}
catch
{
// Any failure mid-build returns the rented buffers before propagating, else they leak:
- // an OOM while filling a list, or GetWitnessHeaders throwing because a walked ancestor
+ // an OOM while filling a list, or BuildHeaders throwing because a walked ancestor
// header vanished (reorg/prune between the call and the witness build).
codes?.Dispose();
state?.Dispose();
@@ -118,6 +93,75 @@ public Witness GetWitness(BlockHeader parentHeader)
}
}
+ ///
+ /// Walks the pre-state trie(s) with to collect every node a
+ /// stateless verifier needs: one pass over the state trie for the touched accounts, then one pass per
+ /// account over its pre-state storage trie for the touched slots. Read/Delete is read off the committed
+ /// post-state (an account that no longer exists, or a slot whose value is now zero, was removed).
+ ///
+ private void CollectStateNodes(BlockHeader parentHeader, CollectingSink sink)
+ {
+ Hash256 stateRoot = parentHeader.StateRoot!;
+
+ // Flat's IReadOnlyTrieStore (FlatReadOnlyTrieStore) resolves nothing until a scope is opened:
+ // BeginScope gathers the read-only snapshot bundle for the parent (blockNumber, stateRoot).
+ // On patricia BeginScope is a no-op, so this is required for flat and harmless for half-path.
+ using IDisposable _ = trieStore.BeginScope(parentHeader);
+
+ if (_storageSlots.Count > 0)
+ {
+ using ArrayPoolList accountEntries = new(_storageSlots.Count);
+ foreach (AddressAsKey address in _storageSlots.Keys)
+ {
+ PatriciaTrieWitnessGenerator.AccessType access = base.AccountExists(address)
+ ? PatriciaTrieWitnessGenerator.AccessType.Read
+ : PatriciaTrieWitnessGenerator.AccessType.Delete;
+ accountEntries.Add(new(address.Value.ToAccountPath, access));
+ }
+ PatriciaTrieWitnessGenerator.Generate(trieStore.GetTrieStore(null), stateRoot, accountEntries.AsSpan(), sink);
+
+ foreach (KeyValuePair> kvp in _storageSlots)
+ {
+ // An account touched only at the account level (e.g. a self-destruct with no SLOAD) has no
+ // slots to walk; removing its state-trie leaf already accounts for its whole storage subtree.
+ if (kvp.Value.Count == 0) continue;
+ Address address = kvp.Key;
+ if (!stateReader.TryGetAccount(parentHeader, address, out AccountStruct account)) continue;
+ ValueHash256 storageRoot = account.StorageRoot;
+ if (storageRoot == Keccak.EmptyTreeHash.ValueHash256) continue;
+
+ using ArrayPoolList slotEntries = new(kvp.Value.Count);
+ foreach (UInt256 slot in kvp.Value)
+ {
+ ValueHash256 slotKey = default;
+ StorageTree.ComputeKeyWithLookup(slot, ref slotKey);
+ bool deleted = base.Get(new StorageCell(address, slot)).IndexOfAnyExcept((byte)0) < 0;
+ slotEntries.Add(new(slotKey, deleted ? PatriciaTrieWitnessGenerator.AccessType.Delete : PatriciaTrieWitnessGenerator.AccessType.Read));
+ }
+ PatriciaTrieWitnessGenerator.Generate(trieStore.GetTrieStore(address), new Hash256(storageRoot), slotEntries.AsSpan(), sink);
+ }
+ }
+
+ // Nothing touched but a non-empty state root: anchor the witness with the root node, which lazy
+ // TrieNode handling can otherwise leave uncollected.
+ if (sink.Nodes.Count == 0 && stateRoot != Keccak.EmptyTreeHash)
+ {
+ IScopedTrieStore stateResolver = trieStore.GetTrieStore(null);
+ TreePath path = TreePath.Empty;
+ TrieNode root = stateResolver.FindCachedOrUnknown(path, stateRoot);
+ root.ResolveNode(stateResolver, path);
+ if (root.Keccak is not null) sink.Add(path, root);
+ }
+ }
+
+ // Not thread-safe (plain dictionary), so the generator is always invoked with parallelize off.
+ private sealed class CollectingSink : PatriciaTrieWitnessGenerator.ISink
+ {
+ public Dictionary Nodes { get; } = [];
+
+ public void Add(in TreePath path, TrieNode node) => Nodes[node.Keccak!] = node.FullRlp.ToArray();
+ }
+
public override bool TryGetAccount(Address address, out AccountStruct account)
{
RecordEmptySlots(address);
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingHeaderFinder.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessHeaderRecorder.cs
similarity index 58%
rename from src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingHeaderFinder.cs
rename to src/Nethermind/Nethermind.Consensus/Stateless/WitnessHeaderRecorder.cs
index d14d4826badb..e003ec0317a8 100644
--- a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessGeneratingHeaderFinder.cs
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessHeaderRecorder.cs
@@ -1,54 +1,61 @@
-// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only
using System;
using Nethermind.Blockchain.Headers;
using Nethermind.Core;
+using Nethermind.Core.Collections;
using Nethermind.Core.Crypto;
using Nethermind.Serialization.Rlp;
-using Nethermind.Core.Collections;
namespace Nethermind.Consensus.Stateless;
-public class WitnessGeneratingHeaderFinder(IHeaderFinder inner) : IHeaderFinder
+///
+/// Per-capture recorder of header reads. The reports every
+/// header lookup here so can emit the contiguous header chain that the
+/// stateless verifier needs.
+///
+///
+/// The chain runs from _lowestRequestedHeader (a low-water mark of every header touched
+/// during execution — e.g. by BLOCKHASH reaching back into the past) to the parent of the recorded
+/// block, in ascending block-number order. The caller passes the undecorated
+/// into so walking the chain at build time
+/// does not re-enter the capture path.
+///
+public sealed class WitnessHeaderRecorder
{
private static readonly HeaderDecoder _decoder = new();
- // Not thread-safe: a pooled rent is reset then used on a single thread, so no synchronization is
- // needed. Concurrent use of one instance would require it.
private long _lowestRequestedHeader = long.MaxValue;
- /// Resets BLOCKHASH bookkeeping so this instance can be reused across pooled rents.
+ /// Resets the low-water mark so the recorder can be reused across pooled env rents.
public void Reset() => _lowestRequestedHeader = long.MaxValue;
- public BlockHeader? Get(Hash256 blockHash, long? blockNumber = null)
+ public void OnHeaderRead(BlockHeader header)
{
- BlockHeader? header = inner.Get(blockHash, blockNumber);
- if (header is not null && header.Number < _lowestRequestedHeader)
- {
- _lowestRequestedHeader = header.Number;
- }
- return header;
+ if (header.Number < _lowestRequestedHeader) _lowestRequestedHeader = header.Number;
}
- public IOwnedReadOnlyList GetWitnessHeaders(Hash256 parentHash)
+ public IOwnedReadOnlyList BuildHeaders(Hash256 parentHash, IHeaderFinder finder)
{
Hash256 currentHash = parentHash;
- BlockHeader parentHeader = inner.Get(currentHash) ?? throw new ArgumentException($"Parent {currentHash} is not found");
+ BlockHeader parentHeader = finder.Get(currentHash) ?? throw new ArgumentException($"Parent {currentHash} is not found");
// BLOCKHASH can only reach below the executed block, so a recorded header above the parent
// means the bookkeeping is broken — fail loudly instead of computing a non-positive count.
if (_lowestRequestedHeader < long.MaxValue && _lowestRequestedHeader > parentHeader.Number)
+ {
throw new InvalidOperationException(
$"Recorded header {_lowestRequestedHeader} is above the executed-against parent {parentHeader.Number}");
+ }
- // Headers in ascending block-number order — any BLOCKHASH-touched ancestor first, recorded
- // block last — so the chain is contiguous and replayable. _lowestRequestedHeader stays at
+ // Headers in ascending block-number order — any BLOCKHASH-touched ancestor first, block being
+ // recorded last — so the chain is contiguous and replayable. _lowestRequestedHeader stays at
// long.MaxValue unless BLOCKHASH reached further back during processing.
int count = _lowestRequestedHeader < long.MaxValue
? (int)(parentHeader.Number - _lowestRequestedHeader + 1)
: 1;
int index = count - 1;
- ArrayPoolList headers = new(count, count);
+ ArrayPoolList headers = new(capacity: count, count);
try
{
headers[index--] = _decoder.Encode(parentHeader).Bytes;
@@ -58,7 +65,7 @@ public IOwnedReadOnlyList GetWitnessHeaders(Hash256 parentHash)
for (long i = parentHeader.Number - 1; i >= _lowestRequestedHeader; i--)
{
currentHash = parentHeader.ParentHash!;
- parentHeader = inner.Get(currentHash, i)
+ parentHeader = finder.Get(currentHash, i)
?? throw new ArgumentException($"Unable to get requested header at hash {currentHash} and number {i} during witness generation");
headers[index--] = _decoder.Encode(parentHeader).Bytes;
}
diff --git a/src/Nethermind/Nethermind.Consensus/Stateless/WitnessRendezvous.cs b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessRendezvous.cs
new file mode 100644
index 000000000000..368e55735958
--- /dev/null
+++ b/src/Nethermind/Nethermind.Consensus/Stateless/WitnessRendezvous.cs
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System.Collections.Concurrent;
+using System.Threading.Tasks;
+using Nethermind.Core.Crypto;
+using Nethermind.Logging;
+
+namespace Nethermind.Consensus.Stateless;
+
+///
+/// Cross-thread coordination between the JSON-RPC handler that requests a witness for a block
+/// hash and the block-processing thread that produces one. The handler awaits a ;
+/// the processor completes it once the matching ProcessOne finishes.
+///
+///
+/// No state-recording concerns live here; this type is purely a hash-keyed
+/// registry. The recorder side ([[witness-capturing-block-processor]]) owns the recording lifecycle and
+/// calls to publish a result.
+///
+public sealed class WitnessRendezvous(ILogManager? logManager = null)
+{
+ private readonly ILogger _logger = (logManager ?? LimboLogs.Instance).GetClassLogger();
+ private readonly ConcurrentDictionary> _pending = new();
+
+ ///
+ /// Handler-side: register a pending witness request for and return
+ /// a that completes when the block is processed (or is cancelled).
+ /// A duplicate request for the same hash cancels the previous task and replaces the entry.
+ ///
+ public Task RequestWitness(Hash256 blockHash)
+ {
+ // RunContinuationsAsynchronously: completion fires from the block-processing thread; we must
+ // not run the handler's continuation inline there.
+ TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource effective = _pending.AddOrUpdate(
+ blockHash,
+ tcs,
+ (_, existing) =>
+ {
+ if (_logger.IsWarn) _logger.Warn($"{nameof(WitnessRendezvous)}: duplicate RequestWitness for {blockHash}. Replacing previous entry.");
+ existing.TrySetCanceled();
+ return tcs;
+ });
+ return effective.Task;
+ }
+
+ /// True iff a witness has been requested for .
+ public bool HasPendingRequest(Hash256 blockHash) => _pending.ContainsKey(blockHash);
+
+ ///
+ /// Handler-side: cancel a pending request (e.g. on the exception path before the processor drains).
+ /// No-op when no entry exists for .
+ ///
+ public void CancelWitnessRequest(Hash256 blockHash)
+ {
+ if (_pending.TryRemove(blockHash, out TaskCompletionSource? tcs))
+ {
+ tcs.TrySetCanceled();
+ if (_logger.IsTrace) _logger.Trace($"{nameof(WitnessRendezvous)}: capture cancelled for {blockHash}");
+ }
+ }
+
+ ///
+ /// Recorder-side: atomically remove and return the pending TCS for .
+ /// Returns false when no request is pending or the entry was already claimed/cancelled.
+ ///
+ ///
+ /// Two-step (claim + complete) rather than a single Complete(hash, witness) so the recorder
+ /// can avoid building the witness when the request was cancelled while processing.
+ ///
+ public bool TryClaim(Hash256 blockHash, out TaskCompletionSource? tcs)
+ => _pending.TryRemove(blockHash, out tcs);
+}
diff --git a/src/Nethermind/Nethermind.Init/Modules/MainProcessingContext.cs b/src/Nethermind/Nethermind.Init/Modules/MainProcessingContext.cs
index db56e0d59997..0ec2a44199df 100644
--- a/src/Nethermind/Nethermind.Init/Modules/MainProcessingContext.cs
+++ b/src/Nethermind/Nethermind.Init/Modules/MainProcessingContext.cs
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
using System;
+using System.Linq;
using System.Threading.Tasks;
using Autofac;
using Nethermind.Api;
@@ -46,9 +47,14 @@ public MainProcessingContext(
builder
// These are main block processing specific
.AddSingleton(worldState)
- .AddModule(blockValidationModules)
+ // Dedupe by type: a module's Load runs once per instance, and re-running a module that
+ // registers a decorator (e.g. WitnessCapturingMainProcessingModule's IBlockProcessor
+ // selector) would install it twice. Duplicate instances arise when more than one module
+ // tree transitively pulls in the same module (e.g. both MergePluginModule and
+ // AuRaMergeModule add BaseMergePluginModule in aura tests).
+ .AddModule([.. blockValidationModules.DistinctBy(static m => m.GetType())])
.AddSingleton(this)
- .AddModule(mainProcessingModules)
+ .AddModule([.. mainProcessingModules.DistinctBy(static m => m.GetType())])
.AddScoped((branchProcessor, processingStats) =>
new BlockchainProcessor(
diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleCallTests.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleCallTests.cs
index e33229425300..8bb23dab1201 100644
--- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleCallTests.cs
+++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleCallTests.cs
@@ -218,8 +218,8 @@ public async Task Proof_call_revert_surfaces_error_and_witness(byte[] runtimeCod
/// when a slot is written (via SSTORE → WorldState.Set) and then reverted (via REVERT → WorldState.Restore),
/// the cached write is discarded and the trie is never traversed during the call. The witness must
/// still include the storage trie nodes for the slot —
- /// re-walks touched keys via MultiAccountProofCollector + per-account AccountProofCollector to
- /// capture them. A cross-client (geth) verifier cannot reconstruct the slot without these nodes.
+ /// walks the touched keys over the pre-state trie with PatriciaTrieWitnessGenerator to capture them.
+ /// A cross-client (geth) verifier cannot reconstruct the slot without these nodes.
///
[Test]
public async Task Proof_call_includes_trie_nodes_for_storage_sstore_then_reverted()
@@ -288,10 +288,9 @@ public async Task Proof_call_includes_trie_nodes_for_storage_sstore_then_reverte
Assert.That(expectedStorageProofNodes, Is.Not.Empty,
"the contract should have a non-empty storage proof for slot 0 in the parent state");
- // The witness must contain every expected storage trie node by hash. If the
- // MultiAccountProofCollector / per-account AccountProofCollector re-walk were dropped, this
- // would fail because the SSTORE was reverted (the trie was never traversed during the call)
- // and only the re-walk could have captured these nodes.
+ // The witness must contain every expected storage trie node by hash. If the post-execution
+ // generator walk were dropped, this would fail because the SSTORE was reverted (the trie was
+ // never traversed during the call) and only walking the touched keys could capture these nodes.
HashSet witnessNodeHashes = result.Witness.State
.Select(Keccak.Compute)
.ToHashSet();
@@ -466,8 +465,8 @@ public async Task Proof_call_witness_lets_a_verifier_reconstruct_state()
}
///
- /// Regression guard: a single-slot call must still capture the state-root node (via the
- /// MultiAccountProofCollector walk); without it, tiny-call witnesses fail stateless re-execution.
+ /// Regression guard: a single-slot call must still capture the state-root node (via the generator
+ /// walk over the touched keys); without it, tiny-call witnesses fail stateless re-execution.
///
[Test]
public async Task Proof_call_single_slot_includes_state_root_in_witness()
@@ -529,9 +528,8 @@ public async Task Proof_call_legacy_tx_with_no_gas_price_on_post_london_chain_ze
}
///
- /// Regression: a call touching two accounts must capture the storage trie for both. The pre-fix
- /// MultiAccountProofCollector keyed its storage-walk discriminator by an address hash
- /// the visitor never provided, so the second account's storage was silently dropped.
+ /// Regression: a call touching two accounts must capture the storage trie for both — the witness
+ /// walk runs per touched account, so neither account's storage is dropped.
///
[Test]
public async Task Proof_call_with_two_accounts_captures_storage_trie_for_each()
diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs
index b9dd1ede23ae..bab3c3b85173 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V1.cs
@@ -2203,6 +2203,7 @@ public async Task Should_warn_for_missing_capabilities()
SszRestPaths.PostV4Forkchoice,
SszRestPaths.PostV2PayloadBodiesByHash,
SszRestPaths.GetV2PayloadBodiesByRange,
+ SszRestCapabilities.NewPayloadWithWitness,
];
public static IEnumerable SszRestPathsAdvertisedCases()
diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs
index 3a4b9daa1d26..304620bd83f7 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.V3.cs
@@ -443,6 +443,7 @@ public async Task NewPayloadV3_should_verify_blob_versioned_hashes_again
Substitute.For?>>(),
Substitute.For, IReadOnlyList>>(),
Substitute.For(),
+ Substitute.For(),
Substitute.For(),
chain.SpecProvider,
new GCKeeper(NoGCStrategy.Instance, chain.LogManager),
diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.WitnessCapture.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.WitnessCapture.cs
new file mode 100644
index 000000000000..8cb39dcc99d4
--- /dev/null
+++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/EngineModuleTests.WitnessCapture.cs
@@ -0,0 +1,661 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Autofac;
+using Nethermind.Consensus.Processing;
+using Nethermind.Consensus.Producers;
+using Nethermind.Consensus.Stateless;
+using Nethermind.Core;
+using Nethermind.Core.Collections;
+using Nethermind.Core.Crypto;
+using Nethermind.Core.Extensions;
+using Nethermind.Core.Test.Builders;
+using Nethermind.Evm.Tracing;
+using Nethermind.Int256;
+using Nethermind.JsonRpc;
+using Nethermind.Merge.Plugin.Data;
+using Nethermind.Merge.Plugin.Handlers;
+using Nethermind.Specs.Forks;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace Nethermind.Merge.Plugin.Test;
+
+public partial class EngineModuleTests
+{
+ private static Witness MakeStubWitness() => new()
+ {
+ State = new ArrayPoolList(1) { new byte[] { 0xDE, 0xAD } },
+ Codes = new ArrayPoolList(0),
+ Keys = new ArrayPoolList(0),
+ Headers = new ArrayPoolList(0),
+ };
+
+ private sealed class WitnessHandlerBuilder
+ {
+ public IEngineRpcModule EngineModule { get; set; }
+ = SucceedingEngineModule(new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA });
+
+ public WitnessRendezvous Rendezvous { get; set; } = new();
+
+ public NewPayloadWithWitnessHandler Build() =>
+ new(new Lazy(() => EngineModule), Rendezvous);
+
+ public static IEngineRpcModule SucceedingEngineModule(PayloadStatusV1 status)
+ {
+ IEngineRpcModule module = Substitute.For();
+ module
+ .engine_newPayloadV5(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Success(status));
+ return module;
+ }
+
+ public static IEngineRpcModule FailingEngineModule(string error, int errorCode)
+ {
+ IEngineRpcModule module = Substitute.For();
+ module
+ .engine_newPayloadV5(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Fail(error, errorCode));
+ return module;
+ }
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public void Rendezvous_RequestWitness_returns_incomplete_task_until_completed()
+ {
+ WitnessRendezvous rendezvous = new();
+
+ Task task = rendezvous.RequestWitness(TestItem.KeccakA);
+
+ Assert.That(task.IsCompleted, Is.False,
+ "the task must remain pending until the block-processor decorator publishes a result");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public void Rendezvous_CancelWitnessRequest_cancels_TCS_and_removes_entry()
+ {
+ WitnessRendezvous rendezvous = new();
+ Hash256 hash = TestItem.KeccakD;
+
+ Task captureTask = rendezvous.RequestWitness(hash);
+ Assert.That(rendezvous.HasPendingRequest(hash), Is.True);
+
+ rendezvous.CancelWitnessRequest(hash);
+
+ Assert.That(rendezvous.HasPendingRequest(hash), Is.False,
+ "CancelWitnessRequest must remove the entry");
+ Assert.That(captureTask.IsCanceled, Is.True,
+ "CancelWitnessRequest must cancel the TCS so any awaiter gets OperationCanceledException");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public void Rendezvous_CancelWitnessRequest_noop_when_no_entry_exists()
+ {
+ WitnessRendezvous rendezvous = new();
+ Action cancel = () => rendezvous.CancelWitnessRequest(Keccak.Zero);
+ Assert.That(cancel, Throws.Nothing, "cancelling a non-existent request is a valid no-op");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public void Rendezvous_duplicate_RequestWitness_cancels_previous_TCS()
+ {
+ WitnessRendezvous rendezvous = new();
+ Hash256 hash = TestItem.KeccakE;
+
+ Task first = rendezvous.RequestWitness(hash);
+ Task second = rendezvous.RequestWitness(hash);
+
+ Assert.That(first.IsCanceled, Is.True,
+ "the orphaned TCS must be cancelled so any awaiter gets OperationCanceledException rather than hanging forever");
+ Assert.That(second.IsCompleted, Is.False, "the replacement TCS is still pending");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task BlockProcessor_completes_rendezvous_task_synchronously_inside_newPayloadV5()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ Hash256 hash = payload.BlockHash!;
+
+ Task captureTask = rendezvous.RequestWitness(hash);
+
+ await chain.EngineRpcModule.engine_newPayloadV5(payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(captureTask.IsCompleted, Is.True,
+ "the block-processor decorator must complete the TCS synchronously inside ProcessOne, " +
+ "before engine_newPayloadV5 returns, so the handler's await is a non-blocking retrieval");
+
+ using Witness? witness = await captureTask;
+ Assert.That(witness, Is.Not.Null, "a VALID block must produce a non-null witness");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task BlockProcessor_does_not_capture_when_no_request_pending()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+
+ await chain.EngineRpcModule.engine_newPayloadV5(payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(rendezvous.HasPendingRequest(payload.BlockHash!), Is.False,
+ "no entry should appear in the rendezvous for a plain engine_newPayloadV5 call");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task BlockProcessor_multi_block_branch_captures_independent_witnesses()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ IEngineRpcModule rpc = chain.EngineRpcModule;
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 p1, byte[][]? r1) = await BuildAmsterdamPayload(chain);
+ Task t1 = rendezvous.RequestWitness(p1.BlockHash!);
+ await rpc.engine_newPayloadV5(p1, [], TestItem.KeccakE, r1 ?? []);
+ await rpc.engine_forkchoiceUpdatedV4(
+ new ForkchoiceStateV1(p1.BlockHash!, p1.BlockHash!, p1.BlockHash!), null);
+ (await t1)?.Dispose();
+
+ (ExecutionPayloadV4 p2, byte[][]? r2) = await BuildAmsterdamPayload(chain);
+ Task t2 = rendezvous.RequestWitness(p2.BlockHash!);
+ await rpc.engine_newPayloadV5(p2, [], TestItem.KeccakE, r2 ?? []);
+
+ Assert.That(t1.IsCompletedSuccessfully, Is.True, "block-1 task was completed during block-1");
+ Assert.That(t2.IsCompletedSuccessfully, Is.True, "block-2 task must be completed during block-2");
+
+ using Witness? w2 = await t2;
+ Assert.That(w2, Is.Not.Null, "block 2 must produce a valid witness");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task BlockProcessor_uncaptured_block_between_two_captured_blocks_leaves_clean_state()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ IEngineRpcModule rpc = chain.EngineRpcModule;
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 p1, byte[][]? r1) = await BuildAmsterdamPayload(chain);
+ Task t1 = rendezvous.RequestWitness(p1.BlockHash!);
+ await rpc.engine_newPayloadV5(p1, [], TestItem.KeccakE, r1 ?? []);
+ await rpc.engine_forkchoiceUpdatedV4(new ForkchoiceStateV1(p1.BlockHash!, p1.BlockHash!, p1.BlockHash!), null);
+ (await t1)?.Dispose();
+
+ (ExecutionPayloadV4 p2, byte[][]? r2) = await BuildAmsterdamPayload(chain);
+ await rpc.engine_newPayloadV5(p2, [], TestItem.KeccakE, r2 ?? []);
+ await rpc.engine_forkchoiceUpdatedV4(new ForkchoiceStateV1(p2.BlockHash!, p2.BlockHash!, p2.BlockHash!), null);
+
+ (ExecutionPayloadV4 p3, byte[][]? r3) = await BuildAmsterdamPayload(chain);
+ Task t3 = rendezvous.RequestWitness(p3.BlockHash!);
+ await rpc.engine_newPayloadV5(p3, [], TestItem.KeccakE, r3 ?? []);
+
+ Assert.That(t3.IsCompletedSuccessfully, Is.True,
+ "an armed capture for block 3 must succeed even after an uncaptured block 2");
+ using Witness? w3 = await t3;
+ Assert.That(w3, Is.Not.Null, "block 3 must produce a valid witness");
+ }
+
+ ///
+ /// Builds an IEngineRpcModule mock whose engine_newPayloadV5 implementation simulates what the
+ /// WitnessCapturingBlockProcessor decorator does on the real path: claim the pending rendezvous
+ /// entry for the requested block hash and publish into it.
+ ///
+ private static IEngineRpcModule PublishingEngineModule(WitnessRendezvous rendezvous, Witness? witness, PayloadStatusV1 status)
+ {
+ IEngineRpcModule module = Substitute.For();
+ module
+ .engine_newPayloadV5(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(call =>
+ {
+ ExecutionPayloadV4 payload = call.Arg();
+ if (rendezvous.TryClaim(payload.BlockHash!, out TaskCompletionSource? tcs))
+ tcs!.SetResult(witness);
+ return ResultWrapper.Success(status);
+ });
+ return module;
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Handler_returns_witness_from_rendezvous_on_valid_status()
+ {
+ using Witness expectedWitness = MakeStubWitness();
+ WitnessRendezvous rendezvous = new();
+
+ NewPayloadWithWitnessHandler handler = new(
+ new Lazy(() => PublishingEngineModule(
+ rendezvous,
+ expectedWitness,
+ new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA })),
+ rendezvous);
+
+ ResultWrapper result =
+ await handler.HandleAsync(new ExecutionPayloadV4 { BlockHash = TestItem.KeccakA }, [], TestItem.KeccakA, []);
+
+ Assert.That(result.Result.ResultType, Is.EqualTo(ResultType.Success));
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ Assert.That(result.Data.ExecutionWitness, Is.SameAs(expectedWitness));
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Handler_valid_status_with_null_witness_yields_null_witness()
+ {
+ WitnessRendezvous rendezvous = new();
+
+ NewPayloadWithWitnessHandler handler = new(
+ new Lazy(() => PublishingEngineModule(
+ rendezvous,
+ witness: null,
+ new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakB })),
+ rendezvous);
+
+ ResultWrapper result =
+ await handler.HandleAsync(new ExecutionPayloadV4 { BlockHash = TestItem.KeccakA }, [], TestItem.KeccakA, []);
+
+ Assert.That(result.Result.ResultType, Is.EqualTo(ResultType.Success));
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ Assert.That(result.Data.ExecutionWitness, Is.Null);
+ }
+
+ private static IEnumerable NonValidOutcomes()
+ {
+ yield return new TestCaseData((Func)(() => WitnessHandlerBuilder.SucceedingEngineModule(
+ new PayloadStatusV1 { Status = PayloadStatus.Syncing })))
+ .SetName("SYNCING status");
+ yield return new TestCaseData((Func)(() => WitnessHandlerBuilder.SucceedingEngineModule(
+ new PayloadStatusV1 { Status = PayloadStatus.Invalid, LatestValidHash = TestItem.KeccakD, ValidationError = "bad block" })))
+ .SetName("INVALID status");
+ yield return new TestCaseData((Func)(() => WitnessHandlerBuilder.FailingEngineModule(
+ "Unsupported fork", MergeErrorCodes.UnsupportedFork)))
+ .SetName("RPC failure");
+ }
+
+ [TestCaseSource(nameof(NonValidOutcomes))]
+ [Category("WitnessCapture")]
+ public async Task Handler_cancels_rendezvous_when_not_valid(Func moduleFactory)
+ {
+ WitnessRendezvous rendezvous = new();
+
+ NewPayloadWithWitnessHandler handler = new(new Lazy(moduleFactory), rendezvous);
+
+ await handler.HandleAsync(new ExecutionPayloadV4 { BlockHash = TestItem.KeccakA }, [], TestItem.KeccakA, []);
+
+ Assert.That(rendezvous.HasPendingRequest(TestItem.KeccakA), Is.False,
+ "the handler must cancel the rendezvous entry on every non-VALID outcome");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Handler_rejects_null_blockHash_with_InvalidParams_and_does_not_register()
+ {
+ WitnessRendezvous rendezvous = new();
+
+ NewPayloadWithWitnessHandler handler = new(
+ new Lazy(() => WitnessHandlerBuilder.SucceedingEngineModule(
+ new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA })),
+ rendezvous);
+
+ ExecutionPayloadV4 payload = new()
+ {
+ BlockHash = null!
+ };
+ ResultWrapper result =
+ await handler.HandleAsync(payload, [], TestItem.KeccakA, []);
+
+ Assert.That(result.Result.ResultType, Is.EqualTo(ResultType.Failure),
+ "a null blockHash is a malformed payload — return InvalidParams instead of forwarding");
+ Assert.That(result.ErrorCode, Is.EqualTo(ErrorCodes.InvalidParams));
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task E2E_empty_Amsterdam_block_produces_VALID_with_non_null_witness()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Result.ResultType, Is.EqualTo(ResultType.Success));
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null, "VALID block must include a witness");
+ Assert.That(witness!.State.Count, Is.GreaterThan(0),
+ "witness State must contain at least the state root proof node");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task E2E_block_with_ETH_transfer_produces_multi_node_witness()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ Transaction tx = Build.A.Transaction
+ .WithValue(UInt256.One)
+ .WithTo(TestItem.AddressB)
+ .WithMaxFeePerGas(20.GWei)
+ .WithMaxPriorityFeePerGas(1.GWei)
+ .WithType(TxType.EIP1559)
+ .SignedAndResolved(chain.EthereumEcdsa, TestItem.PrivateKeyA)
+ .TestObject;
+ chain.AddTransactions(tx);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null);
+ Assert.That(witness!.State.Count, Is.GreaterThan(1),
+ "a transfer touches sender, recipient and fee-recipient: at least 2 proof paths");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task E2E_witness_state_nodes_satisfy_spec_size_constraints()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null);
+
+ foreach (byte[] node in witness!.State)
+ {
+ Assert.That(node, Is.Not.Empty, "every state node must be a non-empty RLP blob");
+ Assert.That(node.Length, Is.LessThanOrEqualTo(1_048_576),
+ "each state element must not exceed MAX_WITNESS_ITEM_BYTES");
+ }
+
+ Assert.That(witness.State.Count, Is.LessThanOrEqualTo(1_048_576),
+ "State list must not exceed MAX_WITNESS_ITEMS");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task E2E_sequential_blocks_produce_independent_witness_instances()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ IEngineRpcModule rpc = chain.EngineRpcModule;
+
+ (ExecutionPayloadV4 p1, byte[][]? r1) = await BuildAmsterdamPayload(chain);
+ ResultWrapper res1 =
+ await rpc.engine_newPayloadWithWitness(p1, [], TestItem.KeccakE, r1 ?? []);
+ await rpc.engine_forkchoiceUpdatedV4(
+ new ForkchoiceStateV1(p1.BlockHash!, p1.BlockHash!, p1.BlockHash!), null);
+
+ (ExecutionPayloadV4 p2, byte[][]? r2) = await BuildAmsterdamPayload(chain);
+ ResultWrapper res2 =
+ await rpc.engine_newPayloadWithWitness(p2, [], TestItem.KeccakE, r2 ?? []);
+
+ Assert.That(res1.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ Assert.That(res2.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+
+ using Witness? w1 = res1.Data.ExecutionWitness;
+ using Witness? w2 = res2.Data.ExecutionWitness;
+
+ Assert.That(w1, Is.Not.Null);
+ Assert.That(w2, Is.Not.Null);
+ Assert.That(w1, Is.Not.SameAs(w2),
+ "each block produces its own Witness instance; shared reference indicates a tracking bug");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task E2E_non_VALID_response_has_null_witness_and_no_rendezvous_leak()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 good, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ ExecutionPayloadV4 bad = new()
+ {
+ BlockHash = Keccak.Zero,
+ ParentHash = good.ParentHash,
+ FeeRecipient = good.FeeRecipient,
+ StateRoot = good.StateRoot,
+ ReceiptsRoot = good.ReceiptsRoot,
+ LogsBloom = good.LogsBloom,
+ PrevRandao = good.PrevRandao,
+ BlockNumber = good.BlockNumber,
+ GasLimit = good.GasLimit,
+ GasUsed = good.GasUsed,
+ Timestamp = good.Timestamp,
+ ExtraData = good.ExtraData,
+ BaseFeePerGas = good.BaseFeePerGas,
+ Transactions = good.Transactions,
+ Withdrawals = good.Withdrawals,
+ BlobGasUsed = good.BlobGasUsed,
+ ExcessBlobGas = good.ExcessBlobGas,
+ ParentBeaconBlockRoot = good.ParentBeaconBlockRoot,
+ ExecutionRequests = good.ExecutionRequests,
+ BlockAccessList = good.BlockAccessList,
+ SlotNumber = good.SlotNumber,
+ };
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(bad, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Result.ResultType, Is.EqualTo(ResultType.Success),
+ "non-VALID status must still yield HTTP 200 / RPC success per the spec");
+ Assert.That(result.Data.Status, Is.Not.EqualTo(PayloadStatus.Valid));
+ Assert.That(result.Data.ExecutionWitness, Is.Null,
+ "spec: witness must be None when status is not VALID");
+
+ Assert.That(rendezvous.HasPendingRequest(Keccak.Zero), Is.False,
+ "the handler must cancel the rendezvous entry on non-VALID outcomes");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Regression_ProcessOne_called_exactly_once_during_engine_newPayloadWithWitness()
+ {
+ int processCount = 0;
+
+ using MergeTestBlockchain chain = await CreateBlockchain(
+ Amsterdam.Instance,
+ configurer: builder =>
+ builder.AddDecorator((_, inner) =>
+ new CountingBranchProcessorDecorator(inner, () => Interlocked.Increment(ref processCount))));
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ processCount = 0;
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ Assert.That(result.Data.ExecutionWitness, Is.Not.Null);
+
+ Assert.That(processCount, Is.EqualTo(1),
+ "Option A must execute the block exactly once; " +
+ "a count of 2 means the old double-execution bug has regressed");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Regression_plain_engine_newPayloadV5_unaffected_by_witness_infrastructure()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+ WitnessRendezvous rendezvous = chain.Container.Resolve();
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ Hash256 hash = payload.BlockHash!;
+
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadV5(payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid),
+ "the witness infrastructure must be completely transparent to the normal path");
+ Assert.That(rendezvous.HasPendingRequest(hash), Is.False,
+ "no rendezvous entry should exist for a plain engine_newPayloadV5 call");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Witness_state_nodes_are_consistent_with_parent_state_root()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ Transaction tx = Build.A.Transaction
+ .WithValue(UInt256.One)
+ .WithTo(TestItem.AddressB)
+ .WithMaxFeePerGas(20.GWei)
+ .WithMaxPriorityFeePerGas(1.GWei)
+ .WithType(TxType.EIP1559)
+ .SignedAndResolved(chain.EthereumEcdsa, TestItem.PrivateKeyA)
+ .TestObject;
+ chain.AddTransactions(tx);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null);
+
+ foreach (byte[] node in witness!.State)
+ {
+ Assert.That(node.Length, Is.GreaterThanOrEqualTo(1),
+ "an empty node indicates drain ran before CommitTree populated the trie cache");
+ }
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Witness_headers_contain_at_least_parent_header()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ Assert.That(result.Data.Status, Is.EqualTo(PayloadStatus.Valid));
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null);
+
+ Assert.That(witness!.Headers.Count, Is.GreaterThanOrEqualTo(1),
+ "Witness.Headers must contain at least the parent block header " +
+ "(WitnessHeaderRecorder.BuildHeaders always includes parentHash).");
+ }
+
+ [Test]
+ [Category("WitnessCapture")]
+ public async Task Witness_headers_items_are_valid_RLP_encoded_block_headers()
+ {
+ using MergeTestBlockchain chain = await CreateBlockchain(Amsterdam.Instance);
+
+ (ExecutionPayloadV4 payload, byte[][]? requests) = await BuildAmsterdamPayload(chain);
+ ResultWrapper result =
+ await chain.EngineRpcModule.engine_newPayloadWithWitness(
+ payload, [], TestItem.KeccakE, requests ?? []);
+
+ using Witness? witness = result.Data.ExecutionWitness;
+ Assert.That(witness, Is.Not.Null);
+
+ foreach (byte[] header in witness!.Headers)
+ {
+ Assert.That(header, Is.Not.Empty, "each header entry must be an RLP-encoded block header");
+ Assert.That(header.Length, Is.LessThanOrEqualTo(1_048_576),
+ "each header must fit within MAX_WITNESS_ITEM_BYTES per execution-apis#773");
+ }
+ }
+
+ private static async Task<(ExecutionPayloadV4 Payload, byte[][]? ExecutionRequests)>
+ BuildAmsterdamPayload(MergeTestBlockchain chain)
+ {
+ IEngineRpcModule rpc = chain.EngineRpcModule;
+ Block head = chain.BlockTree.Head!;
+
+ PayloadAttributes attributes = new()
+ {
+ Timestamp = head.Timestamp + 1,
+ PrevRandao = TestItem.KeccakH,
+ SuggestedFeeRecipient = TestItem.AddressF,
+ Withdrawals = [],
+ ParentBeaconBlockRoot = TestItem.KeccakE,
+ SlotNumber = (ulong?)(head.Number + 1),
+ };
+
+ Hash256 headHash = head.Hash!;
+ ForkchoiceStateV1 fcu = new(headHash, headHash, headHash);
+
+ Task improvementWait = chain.WaitForImprovedBlock(headHash);
+ ResultWrapper fcuResult =
+ await rpc.engine_forkchoiceUpdatedV4(fcu, attributes);
+ Assert.That(fcuResult.Result.ResultType, Is.EqualTo(ResultType.Success));
+
+ await improvementWait;
+
+ byte[] payloadIdBytes = Nethermind.Core.Extensions.Bytes.FromHexString(fcuResult.Data.PayloadId!);
+ ResultWrapper getPayload = await rpc.engine_getPayloadV6(payloadIdBytes);
+ Assert.That(getPayload.Data, Is.Not.Null);
+
+ return (getPayload.Data!.ExecutionPayload, getPayload.Data!.ExecutionRequests);
+ }
+
+ private sealed class CountingBranchProcessorDecorator(IBranchProcessor inner, Action onProcess)
+ : IBranchProcessor
+ {
+ public event EventHandler? BlockProcessed
+ {
+ add => inner.BlockProcessed += value;
+ remove => inner.BlockProcessed -= value;
+ }
+
+ public event EventHandler? BlocksProcessing
+ {
+ add => inner.BlocksProcessing += value;
+ remove => inner.BlocksProcessing -= value;
+ }
+
+ public event EventHandler? BlockProcessing
+ {
+ add => inner.BlockProcessing += value;
+ remove => inner.BlockProcessing -= value;
+ }
+
+ public Block[] Process(
+ BlockHeader? baseBlock,
+ IReadOnlyList suggestedBlocks,
+ ProcessingOptions processingOptions,
+ IBlockTracer blockTracer,
+ CancellationToken token = default)
+ {
+ onProcess();
+ return inner.Process(baseBlock, suggestedBlocks, processingOptions, blockTracer, token);
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs
index 5926fd4728fc..5d99dd82b75f 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs
@@ -9,9 +9,11 @@
using Nethermind.Core.Test.Builders;
using Nethermind.Int256;
using Nethermind.Consensus.Producers;
+using Nethermind.Consensus.Stateless;
using Nethermind.Merge.Plugin.Data;
using Nethermind.Merge.Plugin.SszRest;
using NUnit.Framework;
+using System.Buffers.Binary;
namespace Nethermind.Merge.Plugin.Test.SszRest;
@@ -739,4 +741,133 @@ public void SszKzgCommitment_list_roundtrip_preserves_raw_bytes()
for (int i = 0; i < proofs.Length; i++)
Assert.That(decoded.Commitments![i].AsSpan().ToArray(), Is.EqualTo(proofs[i]), $"commitment {i} bytes must round-trip exactly");
}
+
+ // Witness Union is Some iff status is VALID AND witness is non-null; otherwise None.
+ [TestCase(PayloadStatus.Valid, true, true)]
+ [TestCase(PayloadStatus.Valid, false, false)]
+ [TestCase(PayloadStatus.Invalid, true, false)]
+ [TestCase(PayloadStatus.Syncing, true, false)]
+ [TestCase(PayloadStatus.Accepted, true, false)]
+ public void EncodeNewPayloadWithWitnessResponse_witness_union_presence(string status, bool hasWitness, bool expectedPresent)
+ {
+ using Witness? witness = hasWitness ? MakeMinimalWitness() : null;
+ PayloadStatusV1 ps = new() { Status = status };
+
+ byte[] encoded = Encode(
+ (ps, witness),
+ static (t, w) => SszCodec.EncodeNewPayloadWithWitnessResponse(t.Item1, t.Item2, w));
+
+ (_, _, bool witnessPresent) = SszCodec.DecodeNewPayloadWithWitnessResponse(encoded);
+ Assert.That(witnessPresent, Is.EqualTo(expectedPresent));
+ }
+
+ [Test]
+ public void EncodeNewPayloadWithWitnessResponse_container_header_is_13_bytes_and_offsets_are_correct()
+ {
+ PayloadStatusV1 ps = new() { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA };
+
+ byte[] encoded = Encode(
+ (ps, (Witness?)null),
+ static (t, w) => SszCodec.EncodeNewPayloadWithWitnessResponse(t.Item1, t.Item2, w));
+
+ ReadOnlySpan buf = encoded;
+
+ Assert.That(buf[0], Is.EqualTo(0), "VALID encodes as status byte 0x00");
+
+ int off1 = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(1, 4));
+ Assert.That(off1, Is.EqualTo(13), "latest_valid_hash Union starts immediately after the 13-byte fixed header");
+
+ Assert.That(buf[off1], Is.EqualTo(0x01), "latest_valid_hash Union selector must be 0x01 (Some) when hash is present");
+ Assert.That(buf.Slice(off1 + 1, 32).ToArray(), Is.EqualTo(TestItem.KeccakA.Bytes.ToArray()),
+ "latest_valid_hash bytes must follow immediately after the 0x01 selector");
+
+ int off2 = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(5, 4));
+ Assert.That(off2, Is.EqualTo(46), "validation_error Union starts after latest_valid_hash (13 header + 33 lvh bytes)");
+ Assert.That(buf[off2], Is.EqualTo(0x00), "validation_error Union selector must be 0x00 (None) when no error");
+
+ int off3 = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(9, 4));
+ Assert.That(off3, Is.EqualTo(47), "witness Union starts after validation_error (46 + 1 None byte)");
+ Assert.That(buf[off3], Is.EqualTo(0x00), "witness Union selector must be 0x00 (None) when no witness was generated");
+ }
+
+ [Test]
+ public void EncodeNewPayloadWithWitnessResponse_ssz_golden_byte_roundtrip()
+ {
+ // Fixture-representative witness data: two trie nodes, one code item, one header.
+ byte[] stateNode1 = [0xf8, 0x44, 0x01, 0x02, 0x03];
+ byte[] stateNode2 = [0xe2, 0x80, 0xa0, 0xaa, 0xbb];
+ byte[] codeItem = [0x60, 0x01, 0x60, 0x00, 0x52];
+ byte[] headerBlob = [0xf9, 0x02, 0x18, 0x01, 0x02];
+
+ using Witness witness = new()
+ {
+ State = new Core.Collections.ArrayPoolList(2) { stateNode1, stateNode2 },
+ Codes = new Core.Collections.ArrayPoolList(1) { codeItem },
+ Headers = new Core.Collections.ArrayPoolList(1) { headerBlob },
+ Keys = new Core.Collections.ArrayPoolList(0),
+ };
+
+ Hash256 latestValidHash = TestItem.KeccakB;
+ PayloadStatusV1 ps = new()
+ {
+ Status = PayloadStatus.Valid,
+ LatestValidHash = latestValidHash,
+ };
+
+ byte[] encoded = Encode(
+ (ps, witness),
+ static (t, w) => SszCodec.EncodeNewPayloadWithWitnessResponse(t.Item1, t.Item2, w));
+
+ ReadOnlySpan buf = encoded;
+
+ Assert.That(buf[0], Is.EqualTo(0x00), "VALID encodes as status byte 0x00");
+
+ int offLvh = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(1, 4));
+ Assert.That(offLvh, Is.EqualTo(13), "latest_valid_hash offset must be 13 (right after the fixed header)");
+ Assert.That(buf[offLvh], Is.EqualTo(0x01), "latest_valid_hash selector must be 0x01 (Some)");
+ Assert.That(buf.Slice(offLvh + 1, 32).ToArray(), Is.EqualTo(latestValidHash.Bytes.ToArray()),
+ "latest_valid_hash bytes must match");
+
+ int offVe = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(5, 4));
+ Assert.That(offVe, Is.EqualTo(46), "validation_error offset must be 46 (13 header + 33 Some-hash bytes)");
+ Assert.That(buf[offVe], Is.EqualTo(0x00), "validation_error selector must be 0x00 (None) when absent");
+
+ int offW = BinaryPrimitives.ReadInt32LittleEndian(buf.Slice(9, 4));
+ Assert.That(offW, Is.EqualTo(47), "witness offset must be 47 (46 + 1 None byte for validation_error)");
+
+ (byte decodedStatusByte, Hash256? decodedLvh, bool witnessPresent) =
+ SszCodec.DecodeNewPayloadWithWitnessResponse(encoded);
+
+ Assert.That(decodedStatusByte, Is.EqualTo(0x00), "decoded status byte must be 0x00 (VALID)");
+ Assert.That(decodedLvh, Is.Not.Null, "decoded latest_valid_hash must be present");
+ Assert.That(decodedLvh!.Bytes.ToArray(), Is.EqualTo(latestValidHash.Bytes.ToArray()),
+ "decoded hash must match the original");
+ Assert.That(witnessPresent, Is.True, "VALID + witness bytes => witness union must be Some");
+ }
+
+ [Test]
+ public void EncodeNewPayloadWithWitnessResponse_invalid_status_suppresses_witness()
+ {
+ using Witness witness = MakeMinimalWitness();
+ PayloadStatusV1 ps = new() { Status = PayloadStatus.Invalid };
+
+ byte[] encoded = Encode(
+ (ps, witness),
+ static (t, w) => SszCodec.EncodeNewPayloadWithWitnessResponse(t.Item1, t.Item2, w));
+
+ (byte decodedStatusByte, _, bool witnessPresent) =
+ SszCodec.DecodeNewPayloadWithWitnessResponse(encoded);
+
+ Assert.That(decodedStatusByte, Is.EqualTo(0x01), "INVALID encodes as status byte 0x01");
+ Assert.That(witnessPresent, Is.False,
+ "INVALID status must not carry a witness even when one was passed to the encoder");
+ }
+
+ private static Witness MakeMinimalWitness() => new()
+ {
+ State = new Core.Collections.ArrayPoolList(0),
+ Codes = new Core.Collections.ArrayPoolList(0),
+ Keys = new Core.Collections.ArrayPoolList(0),
+ Headers = new Core.Collections.ArrayPoolList(0),
+ };
}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs
index df46f28a78f0..fb614f76f40d 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszMiddlewareTests.cs
@@ -10,7 +10,9 @@
using Microsoft.AspNetCore.Http;
using Nethermind.Config;
using Nethermind.Consensus.Producers;
+using Nethermind.Consensus.Stateless;
using Nethermind.Core;
+using Nethermind.Core.Collections;
using Nethermind.Core.Authentication;
using Nethermind.Core.Crypto;
using Nethermind.Core.Specs;
@@ -22,6 +24,8 @@
using Nethermind.Merge.Plugin.Data;
using Nethermind.Merge.Plugin.SszRest;
using Nethermind.Merge.Plugin.SszRest.Handlers;
+using Nethermind.Serialization.Rlp;
+using Nethermind.Serialization.Rlp.Eip7928;
using Nethermind.Specs.Forks;
using System.Linq;
using NSubstitute;
@@ -112,6 +116,9 @@ private SszMiddleware BuildMiddleware(RequestDelegate? next = null)
new ClientVersionSszHandler(_engineModule, LimboLogs.Instance),
new CapabilitiesSszHandler(_specProvider),
+
+ // Witness endpoint is a normal handler now (declares FixedPath + JSON RequestContentType).
+ new NewPayloadWithWitnessSszHandler(_engineModule),
];
return new SszMiddleware(
@@ -143,6 +150,15 @@ private static DefaultHttpContext MakePostContext(string path, byte[] body, int
return ctx;
}
+ private static DefaultHttpContext MakeJsonPostContext(string path, byte[] body, int port = AuthenticatedPort)
+ {
+ DefaultHttpContext ctx = MakeBaseContext("POST", path, port);
+ ctx.Request.ContentType = "application/json";
+ ctx.Request.ContentLength = body.Length;
+ ctx.Request.Body = new MemoryStream(body);
+ return ctx;
+ }
+
private static DefaultHttpContext MakeGetContext(string path, int port = AuthenticatedPort)
{
DefaultHttpContext ctx = MakeBaseContext("GET", path, port);
@@ -1042,4 +1058,247 @@ public async Task Error_response_has_correct_RFC7807_shape_with_detail_for_non_c
Assert.That(root.TryGetProperty("detail", out _), Is.True, "unsupported-fork must include 'detail'");
Assert.That(root.EnumerateObject().Count(), Is.EqualTo(2), "error body must have exactly two keys: type + detail");
}
+
+ // The witness endpoint (EIP-7928) is served by SszMiddleware's dedicated fast-path on its own
+ // version-less /new-payload-with-witness path. It speaks application/json (request) and emits
+ // application/octet-stream SSZ on success, RFC 7807 application/problem+json on error.
+
+ [Test]
+ public async Task NewPayloadWithWitness_returns_200_with_octet_stream_and_decodable_ssz_for_valid_status()
+ {
+ Witness stubWitness = new()
+ {
+ State = new ArrayPoolList(1) { new byte[] { 0xDE, 0xAD, 0xBE, 0xEF } },
+ Codes = new ArrayPoolList(0),
+ Keys = new ArrayPoolList(0),
+ Headers = new ArrayPoolList(0),
+ };
+
+ NewPayloadWithWitnessV1Result witnessResult = NewPayloadWithWitnessV1Result.FromPayloadStatus(
+ new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA },
+ stubWitness);
+
+ _engineModule.engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Success(witnessResult));
+
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ await _engineModule.Received(1).engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK),
+ "VALID with a successfully generated witness must return 200 OK");
+ Assert.That(ctx.Response.ContentType, Does.Contain(OctetStream),
+ "successful SSZ responses must use application/octet-stream");
+
+ byte[] responseBody = ResponseBytes(ctx);
+ Assert.That(responseBody, Is.Not.Empty, "the SSZ body must contain the encoded response");
+
+ (byte decodedStatus, Hash256? decodedLvh, bool witnessPresent) = SszCodec.DecodeNewPayloadWithWitnessResponse(responseBody);
+ Assert.That(decodedStatus, Is.EqualTo(0), "decoded status byte must match VALID");
+ Assert.That(decodedLvh, Is.EqualTo(TestItem.KeccakA),
+ "latest_valid_hash Union Some variant must round-trip the hash correctly");
+ Assert.That(witnessPresent, Is.True,
+ "a VALID response with a generated witness must encode the witness as Union Some (selector 0x01)");
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_valid_status_but_witness_generation_fails_returns_200_with_null_witness()
+ {
+ NewPayloadWithWitnessV1Result witnessResult = NewPayloadWithWitnessV1Result.FromPayloadStatus(
+ new PayloadStatusV1 { Status = PayloadStatus.Valid, LatestValidHash = TestItem.KeccakA },
+ witness: null);
+
+ _engineModule.engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Success(witnessResult));
+
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK),
+ "the block is accepted even when witness generation fails; CL must not see 500");
+ Assert.That(ctx.Response.ContentType, Does.Contain(OctetStream));
+
+ byte[] responseBody = ResponseBytes(ctx);
+ Assert.That(responseBody, Is.Not.Empty);
+ (byte decodedStatus, _, bool witnessPresent) = SszCodec.DecodeNewPayloadWithWitnessResponse(responseBody);
+ Assert.That(decodedStatus, Is.EqualTo(0));
+ Assert.That(witnessPresent, Is.False,
+ "when witness generation fails the witness Union field must be None (selector 0x00)");
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_wrong_content_type_post_returns_415()
+ {
+ DefaultHttpContext ctx = MakeBaseContext("POST", "/new-payload-with-witness", AuthenticatedPort);
+ ctx.Request.ContentType = "text/plain";
+ ctx.Request.Body = Stream.Null;
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status415UnsupportedMediaType),
+ "a POST with wrong Content-Type must receive 415, not fall through to 404");
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"));
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_non_valid_status_returns_200_with_ssz_body()
+ {
+ NewPayloadWithWitnessV1Result witnessResult = NewPayloadWithWitnessV1Result.FromPayloadStatus(
+ new PayloadStatusV1 { Status = PayloadStatus.Syncing });
+
+ _engineModule.engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Success(witnessResult));
+
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status200OK),
+ "SYNCING is a normal processing outcome and must return 200, not an HTTP error");
+ Assert.That(ctx.Response.ContentType, Does.Contain(OctetStream));
+ Assert.That(ResponseBytes(ctx), Is.Not.Empty, "the SSZ body must contain the status fields");
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_malformed_json_returns_400_problem_json()
+ {
+ byte[] badBody = System.Text.Encoding.UTF8.GetBytes("not json at all");
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", badBody);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest));
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"),
+ "error responses must be RFC 7807 application/problem+json");
+ string responseBody = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx));
+ Assert.That(responseBody, Does.Contain("\"type\""));
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_malformed_hex_returns_400_not_500()
+ {
+ // Structurally valid JSON, but a hex field carries invalid hex. Nethermind's hex
+ // converters surface this as FormatException/InvalidOperationException rather than
+ // JsonException, so the handler must still map it to 400 instead of leaking a 500.
+ byte[] body = BuildMinimalWitnessRequestBody(blobHashes: new[] { "0xnothex" });
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest));
+ await _engineModule.DidNotReceive().engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_non_post_method_returns_405()
+ {
+ DefaultHttpContext ctx = MakeGetContext("/new-payload-with-witness");
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status405MethodNotAllowed),
+ "spec mandates 405 for any method other than POST on this endpoint");
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"));
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_unsupported_fork_returns_400_with_correct_type()
+ {
+ _engineModule.engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Fail("Unsupported fork", MergeErrorCodes.UnsupportedFork));
+
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status400BadRequest));
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"));
+ string responseBody = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx));
+ Assert.That(responseBody, Does.Contain("/engine-api/errors/unsupported-fork"),
+ "the RFC 7807 type URI for unsupported-fork must be present in the error body");
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_via_versioned_engine_path_returns_404()
+ {
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakePostContext("/engine/v1/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status404NotFound),
+ "the witness endpoint has no versioned /engine/vN/ path; the versioned URL must return 404");
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"),
+ "error responses must always be RFC 7807 application/problem+json");
+ }
+
+ [Test]
+ public async Task NewPayloadWithWitness_non_UnsupportedFork_engine_error_returns_500()
+ {
+ _engineModule.engine_newPayloadWithWitness(
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(ResultWrapper.Fail("Something exploded", ErrorCodes.InternalError));
+
+ byte[] body = BuildMinimalWitnessRequestBody();
+ DefaultHttpContext ctx = MakeJsonPostContext("/new-payload-with-witness", body);
+
+ await _middleware.InvokeAsync(ctx);
+
+ Assert.That(ctx.Response.StatusCode, Is.EqualTo(StatusCodes.Status500InternalServerError),
+ "non-UnsupportedFork engine errors must map to 500 Internal Server Error");
+ Assert.That(ctx.Response.ContentType, Does.Contain("application/problem+json"));
+ string responseBody = System.Text.Encoding.UTF8.GetString(ResponseBytes(ctx));
+ Assert.That(responseBody, Does.Contain("/engine-api/errors/internal"));
+ }
+
+ private static byte[] BuildMinimalWitnessRequestBody(object? blobHashes = null)
+ {
+ ExecutionPayloadV4 payload = new()
+ {
+ ParentHash = TestItem.KeccakA,
+ FeeRecipient = TestItem.AddressA,
+ StateRoot = TestItem.KeccakB,
+ ReceiptsRoot = TestItem.KeccakC,
+ LogsBloom = Bloom.Empty,
+ PrevRandao = TestItem.KeccakD,
+ BlockNumber = 1,
+ GasLimit = 1_000_000,
+ GasUsed = 0,
+ Timestamp = 1_700_000_000,
+ ExtraData = [],
+ BaseFeePerGas = 1,
+ BlockHash = TestItem.KeccakE,
+ Transactions = [],
+ Withdrawals = [],
+ BlobGasUsed = 0,
+ ExcessBlobGas = 0,
+ ParentBeaconBlockRoot = TestItem.KeccakA,
+ ExecutionRequests = [],
+ BlockAccessList = BlockAccessListDecoder.EncodeToBytes(new BlockAccessListBuilder().TestObject)
+ };
+
+ string json = System.Text.Json.JsonSerializer.Serialize(
+ new object?[]
+ {
+ payload,
+ blobHashes ?? Array.Empty(),
+ TestItem.KeccakA,
+ Array.Empty()
+ },
+ Serialization.Json.EthereumJsonSerializer.JsonOptions);
+
+ return System.Text.Encoding.UTF8.GetBytes(json);
+ }
}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/EngineApiJsonContext.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/EngineApiJsonContext.cs
index 0a969638c450..8d4296035a61 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/Data/EngineApiJsonContext.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/EngineApiJsonContext.cs
@@ -3,6 +3,7 @@
using System.Text.Json.Serialization;
using Nethermind.Consensus.Producers;
+using Nethermind.Consensus.Stateless;
using Nethermind.Merge.Plugin.Handlers;
using Nethermind.Serialization.Json;
@@ -15,8 +16,16 @@ namespace Nethermind.Merge.Plugin.Data;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IncludeFields = true,
Converters = new[] { typeof(ByteArrayArrayConverter) })]
+[JsonSerializable(typeof(ExecutionPayload))]
[JsonSerializable(typeof(ExecutionPayloadV3))]
+[JsonSerializable(typeof(ExecutionPayloadV4))]
+[JsonSerializable(typeof(PayloadStatusV1))]
[JsonSerializable(typeof(byte[][]))]
[JsonSerializable(typeof(PayloadAttributes))]
[JsonSerializable(typeof(GetBlobsHandlerV2Request))]
+[JsonSerializable(typeof(ExecutionPayloadBodyV1Result))]
+[JsonSerializable(typeof(TransitionConfigurationV1))]
+[JsonSerializable(typeof(ClientVersionV1))]
+[JsonSerializable(typeof(NewPayloadWithWitnessV1Result))]
+[JsonSerializable(typeof(Witness))]
internal partial class EngineApiJsonContext : JsonSerializerContext;
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Data/NewPayloadWithWitnessV1Result.cs b/src/Nethermind/Nethermind.Merge.Plugin/Data/NewPayloadWithWitnessV1Result.cs
new file mode 100644
index 000000000000..754d34bc57e4
--- /dev/null
+++ b/src/Nethermind/Nethermind.Merge.Plugin/Data/NewPayloadWithWitnessV1Result.cs
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Text.Json.Serialization;
+using Nethermind.Consensus.Stateless;
+using Nethermind.Core.Crypto;
+
+namespace Nethermind.Merge.Plugin.Data;
+
+///
+/// Result of engine_newPayloadWithWitness.
+/// Combines the standard fields with an optional
+/// that is populated when is
+/// .
+///
+///
+public class NewPayloadWithWitnessV1Result : IDisposable
+{
+ public string Status { get; set; } = PayloadStatus.Invalid;
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public Hash256? LatestValidHash { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public string? ValidationError { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Witness? ExecutionWitness { get; set; }
+
+ public static NewPayloadWithWitnessV1Result FromPayloadStatus(PayloadStatusV1 status, Witness? witness = null) =>
+ new()
+ {
+ Status = status.Status,
+ LatestValidHash = status.LatestValidHash,
+ ValidationError = status.ValidationError,
+ ExecutionWitness = witness
+ };
+
+ public void Dispose() => ExecutionWitness?.Dispose();
+}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs
index 576b9a9c4464..96bc24fb0bf3 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs
@@ -18,6 +18,7 @@ public partial class EngineRpcModule : IEngineRpcModule
private readonly IAsyncHandler _getPayloadHandlerV6 = getPayloadHandlerV6;
private readonly IHandler, IReadOnlyList> _executionGetPayloadBodiesByHashV2Handler = getPayloadBodiesByHashV2Handler;
private readonly IGetPayloadBodiesByRangeV2Handler _executionGetPayloadBodiesByRangeV2Handler = getPayloadBodiesByRangeV2Handler;
+ private readonly INewPayloadWithWitnessHandler _newPayloadWithWitnessHandler = newPayloadWithWitnessHandler;
private readonly IAsyncHandler?> _getBlobsHandlerV4 = getBlobsHandlerV4;
@@ -27,6 +28,14 @@ public partial class EngineRpcModule : IEngineRpcModule
public Task> engine_newPayloadV5(ExecutionPayloadV4 executionPayload, Hash256?[] blobVersionedHashes, Hash256? parentBeaconBlockRoot, byte[][]? executionRequests)
=> NewPayload(new ExecutionPayloadParams(executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests), EngineApiVersions.NewPayload.V5);
+ public Task> engine_newPayloadWithWitness(
+ ExecutionPayloadV4 executionPayload,
+ Hash256?[] blobVersionedHashes,
+ Hash256? parentBeaconBlockRoot,
+ byte[][]? executionRequests)
+ => _newPayloadWithWitnessHandler.HandleAsync(
+ executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests);
+
public Task> engine_forkchoiceUpdatedV4(ForkchoiceStateV1 forkchoiceState, PayloadAttributes? payloadAttributes = null, BitArray? custodyColumns = null)
{
// Per execution-apis #793: custody-column updates are best-effort, errors swallowed.
@@ -38,10 +47,13 @@ public Task> engine_forkchoiceUpdatedV4
return ForkchoiceUpdated(forkchoiceState, payloadAttributes, EngineApiVersions.Fcu.V4);
}
- public Task>> engine_getPayloadBodiesByHashV2(IReadOnlyList blockHashes)
+ public Task>> engine_getPayloadBodiesByHashV2(
+ IReadOnlyList blockHashes)
=> _executionGetPayloadBodiesByHashV2Handler.Handle(blockHashes);
- public Task>> engine_getPayloadBodiesByRangeV2(long start, long count)
+ public Task>> engine_getPayloadBodiesByRangeV2(
+ long start,
+ long count)
=> _executionGetPayloadBodiesByRangeV2Handler.Handle(start, count);
public Task?>> engine_getBlobsV4(byte[][] blobVersionedHashes, System.Collections.BitArray indicesBitarray)
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs
index 4c9027783d77..4bea20581bbd 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.cs
@@ -32,6 +32,7 @@ public partial class EngineRpcModule(
IAsyncHandler?> getBlobsHandlerV4,
IHandler, IReadOnlyList> getPayloadBodiesByHashV2Handler,
IGetPayloadBodiesByRangeV2Handler getPayloadBodiesByRangeV2Handler,
+ INewPayloadWithWitnessHandler newPayloadWithWitnessHandler,
IEngineRequestsTracker engineRequestsTracker,
ISpecProvider specProvider,
GCKeeper gcKeeper,
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs
index e101c2326cbc..c27ae33d3966 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/EngineRpcCapabilitiesProvider.cs
@@ -126,6 +126,7 @@ void Configure(string method, string path, RpcCapabilityOptions options)
Configure(nameof(IEngineRpcModule.engine_forkchoiceUpdatedV4), SszRestPaths.PostV4Forkchoice, GateWithWarn(spec.IsEip7843Enabled));
Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByHashV2), SszRestPaths.PostV2PayloadBodiesByHash, GateWithWarn(spec.IsEip7928Enabled));
Configure(nameof(IEngineRpcModule.engine_getPayloadBodiesByRangeV2), SszRestPaths.GetV2PayloadBodiesByRange, GateWithWarn(spec.IsEip7928Enabled));
+ Configure(nameof(IEngineRpcModule.engine_newPayloadWithWitness), SszRestCapabilities.NewPayloadWithWitness, GateWithWarn(spec.IsEip7928Enabled));
json = jsonLocal;
ssz = sszLocal;
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/INewPayloadWithWitnessHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/INewPayloadWithWitnessHandler.cs
new file mode 100644
index 000000000000..41ee0e3df175
--- /dev/null
+++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/INewPayloadWithWitnessHandler.cs
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System.Threading.Tasks;
+using Nethermind.Core.Crypto;
+using Nethermind.JsonRpc;
+using Nethermind.Merge.Plugin.Data;
+
+namespace Nethermind.Merge.Plugin.Handlers;
+
+///
+/// Handles the engine_newPayloadWithWitness RPC method.
+///
+public interface INewPayloadWithWitnessHandler
+{
+ Task> HandleAsync(
+ ExecutionPayloadV4 executionPayload,
+ Hash256?[] blobVersionedHashes,
+ Hash256? parentBeaconBlockRoot,
+ byte[][]? executionRequests);
+}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/Handlers/NewPayloadWithWitnessHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/NewPayloadWithWitnessHandler.cs
new file mode 100644
index 000000000000..8b05780559c7
--- /dev/null
+++ b/src/Nethermind/Nethermind.Merge.Plugin/Handlers/NewPayloadWithWitnessHandler.cs
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Threading.Tasks;
+using Nethermind.Consensus.Stateless;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.JsonRpc;
+using Nethermind.Logging;
+using Nethermind.Merge.Plugin.Data;
+
+namespace Nethermind.Merge.Plugin.Handlers;
+
+///
+/// is taken via to break the construction
+/// cycle (the module composes this handler). On pre-Amsterdam chains the
+/// decorator is not installed, so the rendezvous
+/// TCS for any requested block hash never completes; the cancel-on-non-VALID and
+/// cancel-when-not-completed branches below handle that gracefully.
+///
+public sealed class NewPayloadWithWitnessHandler(
+ Lazy engineModule,
+ WitnessRendezvous rendezvous,
+ ILogManager? logManager = null) : INewPayloadWithWitnessHandler
+{
+ private readonly ILogger _logger = (logManager ?? LimboLogs.Instance).GetClassLogger();
+
+ public async Task> HandleAsync(
+ ExecutionPayloadV4 executionPayload,
+ Hash256?[] blobVersionedHashes,
+ Hash256? parentBeaconBlockRoot,
+ byte[][]? executionRequests)
+ {
+ Hash256? blockHash = executionPayload.BlockHash;
+
+ if (blockHash is null)
+ {
+ if (_logger.IsWarn) _logger.Warn("engine_newPayloadWithWitness: payload BlockHash is null — rejecting as InvalidParams.");
+ return ResultWrapper.Fail(
+ "executionPayload.blockHash is required", ErrorCodes.InvalidParams);
+ }
+
+ Task captureTask = rendezvous.RequestWitness(blockHash);
+
+ ResultWrapper statusResult;
+ try
+ {
+ statusResult = await engineModule.Value.engine_newPayloadV5(
+ executionPayload, blobVersionedHashes, parentBeaconBlockRoot, executionRequests);
+ }
+ catch
+ {
+ rendezvous.CancelWitnessRequest(blockHash);
+ throw;
+ }
+
+ using (statusResult)
+ {
+ if (statusResult.Result.ResultType != ResultType.Success)
+ {
+ rendezvous.CancelWitnessRequest(blockHash);
+ return ResultWrapper.Fail(
+ statusResult.Result.Error ?? "engine_newPayloadV5 failed",
+ statusResult.ErrorCode);
+ }
+
+ PayloadStatusV1 payloadStatus = statusResult.Data!;
+ Witness? witness = null;
+
+ if (payloadStatus.Status == PayloadStatus.Valid)
+ {
+ // BlockProcessor normally completes the TCS synchronously inside ProcessOne.
+ // If it didn't, the block either took an early-return path (already known, etc.)
+ // or the decorator isn't installed (pre-Amsterdam) — cancel so the await below
+ // doesn't block forever.
+ if (!captureTask.IsCompleted)
+ rendezvous.CancelWitnessRequest(blockHash);
+
+ try
+ {
+ witness = await captureTask;
+ }
+ catch (OperationCanceledException)
+ {
+ if (_logger.IsWarn) _logger.Warn($"engine_newPayloadWithWitness: witness capture cancelled for {blockHash}. Returning VALID with no witness.");
+ }
+ }
+ else
+ {
+ rendezvous.CancelWitnessRequest(blockHash);
+ if (captureTask.IsCompletedSuccessfully)
+ (await captureTask)?.Dispose();
+ }
+
+ return ResultWrapper.Success(
+ NewPayloadWithWitnessV1Result.FromPayloadStatus(payloadStatus, witness));
+ }
+ }
+}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs b/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs
index 2efd15d28668..dd522b749605 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/IEngineRpcModule.Amsterdam.cs
@@ -26,6 +26,12 @@ public partial interface IEngineRpcModule : IRpcModule
IsImplemented = true)]
Task> engine_newPayloadV5(ExecutionPayloadV4 executionPayload, Hash256?[] blobVersionedHashes, Hash256? parentBeaconBlockRoot, byte[][]? executionRequests);
+ [JsonRpcMethod(
+ Description = "Verifies the payload according to the execution environment rules and returns the verification status, hash of the last valid block, and the execution witness when the payload is valid.",
+ IsSharable = true,
+ IsImplemented = true)]
+ Task> engine_newPayloadWithWitness(ExecutionPayloadV4 executionPayload, Hash256?[] blobVersionedHashes, Hash256? parentBeaconBlockRoot, byte[][]? executionRequests);
+
[JsonRpcMethod(
Description = "Applies fork choice and starts building a new block if payload attributes are present.",
IsSharable = true,
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs
index 29fcd3eb4c8d..82e867a5f558 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs
@@ -14,11 +14,12 @@
using Nethermind.Config;
using Nethermind.Consensus;
using Nethermind.Consensus.Processing;
+using Nethermind.Core.Container;
using Nethermind.Consensus.Producers;
using Nethermind.Consensus.Rewards;
+using Nethermind.Consensus.Stateless;
using Nethermind.Consensus.Validators;
using Nethermind.Core;
-using Nethermind.Core.Container;
using Nethermind.Core.Crypto;
using Nethermind.Core.Specs;
using Nethermind.Core.Exceptions;
@@ -37,11 +38,11 @@
using Nethermind.Merge.Plugin.SszRest;
using Nethermind.Merge.Plugin.Synchronization;
using Nethermind.Network;
+using Nethermind.Trie.Pruning;
using Nethermind.Serialization.Json;
using Nethermind.Specs.ChainSpecStyle;
using Nethermind.State;
using Nethermind.Synchronization;
-using Nethermind.Trie.Pruning;
using Nethermind.TxPool;
namespace Nethermind.Merge.Plugin;
@@ -264,6 +265,16 @@ protected override void Load(ContainerBuilder builder) => builder
.AddSingleton()
.AddDecorator()
+ .AddSingleton()
+ // Rendezvous lives in the root scope so the JSON-RPC handler can take it directly; the
+ // selector decorator (installed by the main-processing module when EIP-7928 is enabled)
+ // publishes the witness through it.
+ .AddSingleton()
+ // The witness processor graph also lives at root so it builds off the root scope and does
+ // not inherit the main scope's IBlockProcessor selector decorator (which would cycle), while
+ // still wrapping the shared main IWorldState. Built lazily on the first witnessed block.
+ .AddSingleton()
+
.AddSingleton()
.ResolveOnServiceActivation()
@@ -304,6 +315,7 @@ protected override void Load(ContainerBuilder builder) => builder
.AddSingleton?>, GetBlobsHandlerV4>()
.AddSingleton, IReadOnlyList>, GetPayloadBodiesByHashV2Handler>()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton((ctx) =>
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ISszEndpointHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ISszEndpointHandler.cs
index 84e36c530406..9e408eddb55c 100644
--- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ISszEndpointHandler.cs
+++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/ISszEndpointHandler.cs
@@ -29,4 +29,20 @@ public interface ISszEndpointHandler
Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body);
bool AcceptsPathExtra => false;
+
+ ///
+ /// When non-null, binds the handler to this exact, version-less request path
+ /// (e.g. /new-payload-with-witness), bypassing the
+ /// /engine/v2/{fork}/{resource} fork/version routing scheme. Null for the
+ /// fork-routed endpoints, which are matched by +
+ /// + instead.
+ ///
+ string? FixedPath => null;
+
+ ///
+ /// Media type the request body must carry (matched against Content-Type for POST,
+ /// Accept for GET). Defaults to application/octet-stream, the SSZ-REST
+ /// hot-path encoding; endpoints that exchange JSON (e.g. the witness endpoint) override it.
+ ///
+ string RequestContentType => "application/octet-stream";
}
diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/NewPayloadWithWitnessSszHandler.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/NewPayloadWithWitnessSszHandler.cs
new file mode 100644
index 000000000000..77055cbc5bfe
--- /dev/null
+++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/NewPayloadWithWitnessSszHandler.cs
@@ -0,0 +1,154 @@
+// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
+// SPDX-License-Identifier: LGPL-3.0-only
+
+using System;
+using System.Buffers;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Nethermind.Consensus.Stateless;
+using Nethermind.Core;
+using Nethermind.Core.Crypto;
+using Nethermind.JsonRpc;
+using Nethermind.Merge.Plugin.Data;
+using Nethermind.Serialization.Json;
+
+namespace Nethermind.Merge.Plugin.SszRest.Handlers;
+
+///
+/// Handles POST /new-payload-with-witness. Per execution-apis#773 the request is JSON
+/// (same shape as engine_newPayloadV5 params) and the response is SSZ-encoded
+/// NewPayloadWithWitnessResponseV1 — the only mixed-format endpoint in the SSZ-REST surface.
+///
+public sealed class NewPayloadWithWitnessSszHandler(
+ IEngineRpcModule engineModule) : SszEndpointHandlerBase
+{
+
+ public override string HttpMethod => "POST";
+
+ public override string Resource => SszRestPaths.NewPayloadWithWitness;
+ public override int? Version => null;
+
+ // The only mixed-format endpoint: its own version-less path, and a JSON (not octet-stream)
+ // request body. Declaring both lets SszMiddleware route and content-negotiate it generically.
+ public override string? FixedPath => SszRestPaths.NewPayloadWithWitnessPath;
+ public override string RequestContentType => "application/json";
+
+ public override async Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body)
+ {
+ NewPayloadV5Params? request = DeserializeRequest(body);
+ if (request is null)
+ {
+ await WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, "Malformed JSON body or invalid parameter shapes", ErrorCodes.ParseError);
+ return;
+ }
+
+ ResultWrapper