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 result = await engineModule.engine_newPayloadWithWitness( + request.ExecutionPayload, + request.ExpectedBlobVersionedHashes, + request.ParentBeaconBlockRoot, + request.ExecutionRequests); + + using (result) + { + if (result.Result.ResultType != ResultType.Success) + { + int httpStatus = result.ErrorCode switch + { + MergeErrorCodes.UnsupportedFork => StatusCodes.Status400BadRequest, + _ => StatusCodes.Status500InternalServerError, + }; + int jsonRpcCode = result.ErrorCode switch + { + MergeErrorCodes.UnsupportedFork => MergeErrorCodes.UnsupportedFork, + _ => ErrorCodes.InternalError, + }; + await WriteErrorAsync(ctx, httpStatus, result.Result.Error ?? "Unknown error", jsonRpcCode); + return; + } + + NewPayloadWithWitnessV1Result witnessResult = result.Data!; + PayloadStatusV1 payloadStatus = new() + { + Status = witnessResult.Status, + LatestValidHash = witnessResult.LatestValidHash, + ValidationError = witnessResult.ValidationError + }; + await WriteSszNewPayloadWithWitnessAsync(ctx, payloadStatus, witnessResult.ExecutionWitness); + } + } + + // Witness ownership stays with the caller — the enclosing ResultWrapper disposes it. + private static async Task WriteSszNewPayloadWithWitnessAsync(HttpContext ctx, PayloadStatusV1 status, Witness? witness) + { + ArrayBufferWriter buffer = new(); + int length; + try + { + length = SszCodec.EncodeNewPayloadWithWitnessResponse(status, witness, buffer); + } + catch + { + ctx.Abort(); + throw; + } + + ctx.Response.ContentType = "application/octet-stream"; + ctx.Response.ContentLength = length; + ctx.Response.StatusCode = StatusCodes.Status200OK; + + System.IO.Pipelines.PipeWriter pipe = ctx.Response.BodyWriter; + try + { + await pipe.WriteAsync(buffer.WrittenMemory, ctx.RequestAborted); + } + catch + { + ctx.Abort(); + throw; + } + + await ctx.Response.CompleteAsync(); + } + + private static NewPayloadV5Params? DeserializeRequest(ReadOnlySequence body) + { + try + { + Utf8JsonReader reader = new(body); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) return null; + + if (!reader.Read()) return null; + ExecutionPayloadV4? payload = JsonSerializer.Deserialize( + ref reader, EthereumJsonSerializer.JsonOptions); + if (payload is null) return null; + + if (!reader.Read()) return null; + Hash256?[]? blobHashes = JsonSerializer.Deserialize( + ref reader, EthereumJsonSerializer.JsonOptions); + + if (!reader.Read()) return null; + Hash256? parentBeaconBlockRoot = JsonSerializer.Deserialize( + ref reader, EthereumJsonSerializer.JsonOptions); + + if (!reader.Read()) return null; + byte[][]? executionRequests = JsonSerializer.Deserialize( + ref reader, EthereumJsonSerializer.JsonOptions); + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray) return null; + + return new NewPayloadV5Params(payload, blobHashes ?? [], parentBeaconBlockRoot, executionRequests); + } + catch (Exception e) when (e is JsonException or FormatException or InvalidOperationException or OverflowException or ArgumentException) + { + return null; + } + } + + private sealed record NewPayloadV5Params( + ExecutionPayloadV4 ExecutionPayload, + Hash256?[] ExpectedBlobVersionedHashes, + Hash256? ParentBeaconBlockRoot, + byte[][]? ExecutionRequests); +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs index bc5072cc7120..ec340323811d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszEndpointHandlerBase.cs @@ -29,6 +29,10 @@ public abstract class SszEndpointHandlerBase : ISszEndpointHandler public virtual bool AcceptsPathExtra => false; + public virtual string? FixedPath => null; + + public virtual string RequestContentType => OctetStream; + /// public abstract Task HandleAsync(HttpContext ctx, int version, ReadOnlyMemory extra, ReadOnlySequence body); diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs index d7f83b522910..62f325c7cee7 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/Handlers/SszRestPaths.cs @@ -75,6 +75,13 @@ public static class SszRestPaths public const string Blobs = "blobs"; + // Witness endpoint resource segment (EIP-7928). The handler declares its own version-less + // FixedPath, so SszMiddleware routes it by exact path rather than the fork-segment router. + public const string NewPayloadWithWitness = "new-payload-with-witness"; + + // Absolute request path the witness endpoint binds to (ISszEndpointHandler.FixedPath). + public const string NewPayloadWithWitnessPath = "/" + NewPayloadWithWitness; + // Documentation strings for the SSZ-REST routes — used by EngineRpcCapabilitiesProvider // (registration) and EngineModuleTests (coverage assertions). Built at static-init time from // each fork's EngineApiUrlSegment so the route docs stay in sync with the routing layer. @@ -152,3 +159,13 @@ public static class SszRestPaths return null; } } + +/// +/// Engine API capability names that are advertised by engine_exchangeCapabilities but are +/// not part of the standard POST /engine/v2/{fork}/{resource} path scheme. The EIP-7928 +/// witness endpoint has its own dedicated, version-less path and is advertised under this name. +/// +public static class SszRestCapabilities +{ + public const string NewPayloadWithWitness = "rest_engine_newPayloadWithWitness"; +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs index 4cc989a02548..f2fc97e803eb 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs @@ -5,21 +5,23 @@ using System.Buffers; using System.Collections.Generic; using System.Text; +using Nethermind.Consensus.Stateless; using Nethermind.Core; +using Nethermind.Core.Collections; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Consensus.Producers; using Nethermind.Merge.Plugin.Data; using Nethermind.Serialization.Ssz; +using System.Buffers.Binary; namespace Nethermind.Merge.Plugin.SszRest; public static class SszCodec { - /// - /// SSZ-encodes directly into 's buffer - /// (no intermediate pooled allocation) and returns the number of bytes written. - /// + private const int ValidationErrorMaxBytes = 1024; + + /// Encode directly into the writer's buffer (no intermediate alloc); returns bytes written. private static int EncodeToWriter(T value, IBufferWriter writer) where T : ISszCodec { int length = T.GetLength(value); @@ -32,6 +34,138 @@ private static int EncodeToWriter(T value, IBufferWriter writer) where public static int EncodePayloadStatus(PayloadStatusV1 ps, IBufferWriter writer) => EncodeToWriter(BuildPayloadStatusWire(ps), writer); + public static int EncodeNewPayloadWithWitnessResponse(PayloadStatusV1 ps, Witness? witness, IBufferWriter writer) + { + const int FixedHeaderBytes = 1 + 4 + 4 + 4; + + bool hasLvh = ps.LatestValidHash is not null; + int lvhLen = hasLvh ? 33 : 1; + + byte[] errorBytes = ps.ValidationError is not null + ? Encoding.UTF8.GetBytes(ps.ValidationError) + : []; + if (errorBytes.Length > ValidationErrorMaxBytes) + errorBytes = TruncateUtf8(errorBytes, ValidationErrorMaxBytes); + bool hasError = ps.ValidationError is not null; + int errorLen = hasError ? 1 + errorBytes.Length : 1; + + bool hasWitness = witness is not null && ps.Status == PayloadStatus.Valid; + ExecutionWitnessV1Wire witnessWire = hasWitness ? BuildExecutionWitnessV1Wire(witness!) : default; + int witnessBodyLen = hasWitness ? ExecutionWitnessV1Wire.GetLength(witnessWire) : 0; + int witnessLen = hasWitness ? 1 + witnessBodyLen : 1; + + int totalLen = FixedHeaderBytes + lvhLen + errorLen + witnessLen; + + // No dst.Clear() — every byte in [0, totalLen) is overwritten below. + Span dst = writer.GetSpan(totalLen)[..totalLen]; + + int pos = 0; + + dst[pos++] = EngineStatusToSsz(ps.Status); + + // SSZ offsets are uint32; all three are bounded by MaxBodySize (16 MiB) << 2^31. + uint off1 = (uint)FixedHeaderBytes; + BinaryPrimitives.WriteUInt32LittleEndian(dst.Slice(pos, 4), off1); + pos += 4; + + uint off2 = off1 + (uint)lvhLen; + BinaryPrimitives.WriteUInt32LittleEndian(dst.Slice(pos, 4), off2); + pos += 4; + + uint off3 = off2 + (uint)errorLen; + BinaryPrimitives.WriteUInt32LittleEndian(dst.Slice(pos, 4), off3); + pos += 4; + + if (hasLvh) + { + dst[pos++] = 0x01; + ps.LatestValidHash!.Bytes.CopyTo(dst.Slice(pos, 32)); + pos += 32; + } + else + { + dst[pos++] = 0x00; + } + + if (hasError) + { + dst[pos++] = 0x01; + errorBytes.CopyTo(dst.Slice(pos, errorBytes.Length)); + pos += errorBytes.Length; + } + else + { + dst[pos++] = 0x00; + } + + if (hasWitness) + { + dst[pos++] = 0x01; + ExecutionWitnessV1Wire.Encode(dst.Slice(pos, witnessBodyLen), witnessWire); + pos += witnessBodyLen; + } + else + { + dst[pos++] = 0x00; + } + + if (pos != totalLen) + throw new InvalidOperationException($"NewPayloadWithWitnessResponseV1 encode length mismatch: wrote {pos} bytes but expected {totalLen}"); + writer.Advance(totalLen); + return totalLen; + } + + /// Test helper: round-trip decode of NewPayloadWithWitnessResponseV1 SSZ output. + public static (byte Status, Hash256? LatestValidHash, bool WitnessPresent) + DecodeNewPayloadWithWitnessResponse(ReadOnlySpan data) + { + const int FixedHeaderBytes = 1 + 4 + 4 + 4; + if (data.Length < FixedHeaderBytes) + throw new ArgumentException("Response too short to be a valid NewPayloadWithWitnessResponseV1"); + + byte status = data[0]; + // Spec offsets are uint32; cast to int for slicing (we're well within int range). + int off1 = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(1, 4)); + int off2 = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(5, 4)); + int off3 = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(9, 4)); + + ReadOnlySpan lvhSlice = data.Slice(off1, off2 - off1); + Hash256? latestValidHash = null; + if (lvhSlice.Length >= 1 && lvhSlice[0] == 0x01) + latestValidHash = new Hash256(lvhSlice.Slice(1, 32)); + + ReadOnlySpan witnessSlice = data.Slice(off3); + bool witnessPresent = witnessSlice.Length >= 1 && witnessSlice[0] == 0x01; + + return (status, latestValidHash, witnessPresent); + } + + private static ExecutionWitnessV1Wire BuildExecutionWitnessV1Wire(Witness witness) + { + return new ExecutionWitnessV1Wire + { + State = ToWitnessItems(witness.State), + Codes = ToWitnessItems(witness.Codes), + Headers = ToWitnessItems(witness.Headers) + }; + + static SszWitnessItem[] ToWitnessItems(IOwnedReadOnlyList items) + { + SszWitnessItem[] result = new SszWitnessItem[items.Count]; + for (int i = 0; i < items.Count; i++) + result[i] = new SszWitnessItem { Bytes = items[i] }; + return result; + } + } + + private static byte[] TruncateUtf8(byte[] utf8, int maxBytes) + { + int i = maxBytes; + while (i > 0 && (utf8[i] & 0xC0) == 0x80) + i--; + return utf8[..i]; + } + public static int EncodeForkchoiceUpdatedResponse(ForkchoiceUpdatedV1Result resp, IBufferWriter writer) { SszPayloadId[]? pidList = null; @@ -308,7 +442,6 @@ public static int EncodeClientVersionResponse(ClientVersionV1[] versions, IBuffe private static PayloadStatusWire BuildPayloadStatusWire(PayloadStatusV1 ps) { - const int MaxErrorBytes = 1024; SszValidationError[] error; if (ps.ValidationError is null) { @@ -317,7 +450,7 @@ private static PayloadStatusWire BuildPayloadStatusWire(PayloadStatusV1 ps) else { byte[] errorBytes = Encoding.UTF8.GetBytes(ps.ValidationError); - if (errorBytes.Length > MaxErrorBytes) errorBytes = errorBytes[..MaxErrorBytes]; + if (errorBytes.Length > ValidationErrorMaxBytes) errorBytes = errorBytes[..ValidationErrorMaxBytes]; error = [new SszValidationError { Bytes = errorBytes }]; } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs index 0818852dc7ed..842c6e0700dd 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs @@ -47,6 +47,10 @@ public sealed class SszMiddleware private readonly FrozenDictionary>.AlternateLookup> _postLookup; private readonly FrozenDictionary>.AlternateLookup> _getLookup; + // Handlers that declare an exact ISszEndpointHandler.FixedPath, keyed by that path. These bypass the + // /engine/v2/{fork}/{resource} fork/version router (e.g. the EIP-7928 witness endpoint). + private readonly FrozenDictionary _fixedPathRoutes; + private enum SszRequestKind { NotEngine, EngineWrongMediaType, EngineOk } public SszMiddleware( @@ -62,20 +66,30 @@ public SszMiddleware( _auth = auth; _logger = logManager.GetClassLogger(); _processExitToken = processExitSource.Token; - (_postRoutes, _getRoutes) = BuildRoutes(handlers); + (_postRoutes, _getRoutes, _fixedPathRoutes) = BuildRoutes(handlers); _postLookup = _postRoutes.GetAlternateLookup>(); _getLookup = _getRoutes.GetAlternateLookup>(); } private static (FrozenDictionary> post, - FrozenDictionary> get) + FrozenDictionary> get, + FrozenDictionary fixedPath) BuildRoutes(IEnumerable handlers) { Dictionary> postDict = []; Dictionary> getDict = []; + Dictionary fixedPathDict = new(StringComparer.OrdinalIgnoreCase); foreach (ISszEndpointHandler h in handlers) { + // A handler with a FixedPath is matched by exact path, not the fork/version scheme. + if (h.FixedPath is { } fixedPath) + { + if (!fixedPathDict.TryAdd(fixedPath, h)) + throw new InvalidOperationException($"Duplicate {nameof(ISszEndpointHandler.FixedPath)} '{fixedPath}'."); + continue; + } + // Dictionaries are keyed case-insensitively below — keep resource as-is, no lowercasing. Dictionary> dict = h.HttpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) @@ -90,8 +104,9 @@ private static (FrozenDictionary> post, FrozenDictionary> post = postDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); FrozenDictionary> get = getDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + FrozenDictionary fixedPaths = fixedPathDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - return (post, get); + return (post, get, fixedPaths); } public Task InvokeAsync(HttpContext ctx) @@ -136,6 +151,10 @@ private async Task ProcessSszRequestAsync(HttpContext ctx) await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status401Unauthorized, "Authentication error"); } + else if (_fixedPathRoutes.TryGetValue(ctx.Request.Path.Value ?? string.Empty, out ISszEndpointHandler? fixedHandler)) + { + await DispatchFixedPathAsync(ctx, fixedHandler); + } else if (!TryRoute(ctx.Request.Path.Value ?? string.Empty, out int version, out string? fork, out ReadOnlyMemory pathSegment, out bool unsupportedFork)) { @@ -175,70 +194,111 @@ await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status404NotFound, : $"SSZ-REST {ctx.Request.Method} /engine/v2/{pathSegment.Span}/{extra.Span}"); } - // Read directly from PipeReader: the buffer is a ReadOnlySequence over Kestrel's - // pooled blocks (~4 KB each), so multi-segment is the common case for blob-bearing - // payloads. The generated SSZ codecs accept ReadOnlySequence — single-segment - // is zero-copy, multi-segment consolidates once via ArrayPool. Both paths skip the - // MemoryStream + ToArray dance the previous implementation needed. - PipeReader reader = ctx.Request.BodyReader; - ReadOnlySequence body = default; - bool bodyRead = false; - try - { - body = await ReadBodyAsync(ctx, reader); - bodyRead = true; - Metrics.SszRestRequestBytesTotal += body.Length; + await DispatchAsync(ctx, handler!, version, extra); + } + } - await handler!.HandleAsync(ctx, version, extra, body); + /// + /// Dispatch for a handler bound to an exact . Validates + /// the method (against ) and request content-type + /// (against ) before delegating to + /// . + /// + private async Task DispatchFixedPathAsync(HttpContext ctx, ISszEndpointHandler handler) + { + if (!ctx.Request.Method.Equals(handler.HttpMethod, StringComparison.OrdinalIgnoreCase)) + { + Metrics.SszRestRequestsClientErrorTotal++; + ctx.Response.Headers.Allow = handler.HttpMethod; + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status405MethodNotAllowed, + $"Method '{ctx.Request.Method}' is not allowed on {handler.FixedPath}. Only {handler.HttpMethod} is supported.", + SszRestErrorCodes.MethodNotFound); + return; + } - int status = ctx.Response.StatusCode; - switch (status) - { - case >= 200 and < 300: - Metrics.SszRestRequestsSuccessTotal++; - break; - case >= 400 and < 500: - Metrics.SszRestRequestsClientErrorTotal++; - break; - case >= 500: - Metrics.SszRestRequestsServerErrorTotal++; - break; - } - } - catch (InvalidOperationException ex) when (!bodyRead) - { - Metrics.SszRestRequestsClientErrorTotal++; - await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, ex.Message); - } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) - { - // Per execution-apis #793 (Engine API SSZ Transport spec, "HTTP status codes" section): - // malformed SSZ encoding is 400 Bad Request with type=ssz-decode-error: canned error, - // no detail (spec verbatim). 422 Unprocessable Entity is reserved for - // "Invalid payload attributes" and is emitted by the handler chain via - // ErrorCodeToHttpStatus when the engine module returns InvalidPayloadAttributes. - Metrics.SszRestDecodeFailuresTotal++; - Metrics.SszRestRequestsClientErrorTotal++; - if (_logger.IsDebug) _logger.Debug($"SSZ-REST malformed body at {ctx.Request.Path.Value}: {ex.Message}"); - await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, - string.Empty, SszRestErrorCodes.SszDecodeError); - } - catch (Exception ex) - { - Metrics.SszRestRequestsServerErrorTotal++; - if (_logger.IsError) _logger.Error($"SSZ-REST handler error for {ctx.Request.Path.Value}", ex); - - // If the inner code already aborted the request (e.g. encode failed mid-stream - // and called ctx.Abort), don't try to write a 500 — WriteAsync would throw - // OperationCanceledException, producing a duplicate exception in the logs. - if (!ctx.RequestAborted.IsCancellationRequested) - await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, "Internal server error"); - } - finally + string? contentType = ctx.Request.ContentType; + if (contentType is null || !contentType.Contains(handler.RequestContentType, StringComparison.OrdinalIgnoreCase)) + { + Metrics.SszRestRequestsClientErrorTotal++; + ctx.Response.Headers["Accept"] = handler.RequestContentType; + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status415UnsupportedMediaType, + $"Content-Type must be {handler.RequestContentType} for {handler.FixedPath}.", + SszRestErrorCodes.UnsupportedMediaType); + return; + } + + if (_logger.IsTrace) _logger.Trace($"SSZ-REST {handler.HttpMethod} {handler.FixedPath}"); + + await DispatchAsync(ctx, handler, handler.Version ?? 0, extra: default); + } + + /// Shared body-read + handler invocation + metrics + error mapping for both the + /// fork-routed endpoints and the witness fast-path. + private async Task DispatchAsync(HttpContext ctx, ISszEndpointHandler handler, int version, ReadOnlyMemory extra) + { + // Read directly from PipeReader: the buffer is a ReadOnlySequence over Kestrel's + // pooled blocks (~4 KB each), so multi-segment is the common case for blob-bearing + // payloads. The generated SSZ codecs accept ReadOnlySequence — single-segment + // is zero-copy, multi-segment consolidates once via ArrayPool. Both paths skip the + // MemoryStream + ToArray dance the previous implementation needed. + PipeReader reader = ctx.Request.BodyReader; + ReadOnlySequence body = default; + bool bodyRead = false; + try + { + body = await ReadBodyAsync(ctx, reader); + bodyRead = true; + Metrics.SszRestRequestBytesTotal += body.Length; + + await handler.HandleAsync(ctx, version, extra, body); + + int status = ctx.Response.StatusCode; + switch (status) { - if (bodyRead) reader.AdvanceTo(body.End); + case >= 200 and < 300: + Metrics.SszRestRequestsSuccessTotal++; + break; + case >= 400 and < 500: + Metrics.SszRestRequestsClientErrorTotal++; + break; + case >= 500: + Metrics.SszRestRequestsServerErrorTotal++; + break; } } + catch (InvalidOperationException ex) when (!bodyRead) + { + Metrics.SszRestRequestsClientErrorTotal++; + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status413PayloadTooLarge, ex.Message); + } + catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException) + { + // Per execution-apis #793 (Engine API SSZ Transport spec, "HTTP status codes" section): + // malformed SSZ encoding is 400 Bad Request with type=ssz-decode-error: canned error, + // no detail (spec verbatim). 422 Unprocessable Entity is reserved for + // "Invalid payload attributes" and is emitted by the handler chain via + // ErrorCodeToHttpStatus when the engine module returns InvalidPayloadAttributes. + Metrics.SszRestDecodeFailuresTotal++; + Metrics.SszRestRequestsClientErrorTotal++; + if (_logger.IsDebug) _logger.Debug($"SSZ-REST malformed body at {ctx.Request.Path.Value}: {ex.Message}"); + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status400BadRequest, + string.Empty, SszRestErrorCodes.SszDecodeError); + } + catch (Exception ex) + { + Metrics.SszRestRequestsServerErrorTotal++; + if (_logger.IsError) _logger.Error($"SSZ-REST handler error for {ctx.Request.Path.Value}", ex); + + // If the inner code already aborted the request (e.g. encode failed mid-stream + // and called ctx.Abort), don't try to write a 500 — WriteAsync would throw + // OperationCanceledException, producing a duplicate exception in the logs. + if (!ctx.RequestAborted.IsCancellationRequested) + await SszEndpointHandlerBase.WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, "Internal server error"); + } + finally + { + if (bodyRead) reader.AdvanceTo(body.End); + } } private static bool TryRoute(string path, out int version, out string? fork, @@ -408,10 +468,17 @@ private bool TryResolveHandler(string method, ReadOnlyMemory pathSegment, } - private static SszRequestKind ClassifySszRequest(HttpContext ctx) + private SszRequestKind ClassifySszRequest(HttpContext ctx) { string path = ctx.Request.Path.Value ?? string.Empty; + // Fixed-path endpoints (e.g. the witness endpoint) are intercepted for ALL methods so that + // non-matching method / content-type get a proper 405/415 from DispatchFixedPathAsync rather + // than falling through to the next middleware and returning a confusing 404. The method and + // content-type checks are deferred to DispatchFixedPathAsync (the route may not be octet-stream). + if (_fixedPathRoutes.ContainsKey(path)) + return SszRequestKind.EngineOk; + if (!path.StartsWith("/engine/", StringComparison.OrdinalIgnoreCase)) return SszRequestKind.NotEngine; diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs index 7fae8d0e0a69..2b61399f689b 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddlewareConfigurer.cs @@ -81,6 +81,10 @@ public void Configure(IServiceCollection services) foreach (Type handler in SingletonHandlers) services.AddSingleton(typeof(ISszEndpointHandler), handler); + + // EIP-7928 witness endpoint: a normal handler that declares a FixedPath + JSON RequestContentType, + // so SszMiddleware routes it through the same machinery as everything else (no special-casing). + services.AddSingleton(); } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs index c4866b061895..f47482134259 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs @@ -445,3 +445,16 @@ public partial struct GetBlobsV4ResponseWire { [SszList(128)] public BlobV4EntryWire[]? Entries { get; set; } } +[SszContainer(isCollectionItself: true)] +public partial struct SszWitnessItem +{ + [SszList(1048576)] public byte[]? Bytes { get; set; } +} + +[SszContainer] +public partial struct ExecutionWitnessV1Wire +{ + [SszList(1048576)] public SszWitnessItem[]? State { get; set; } + [SszList(1048576)] public SszWitnessItem[]? Codes { get; set; } + [SszList(1048576)] public SszWitnessItem[]? Headers { get; set; } +} diff --git a/src/Nethermind/Nethermind.State.Test/PatriciaTreeBulkSetterTests.cs b/src/Nethermind/Nethermind.State.Test/PatriciaTreeBulkSetterTests.cs index 7dcd8a338f58..f0304f2d83f3 100644 --- a/src/Nethermind/Nethermind.State.Test/PatriciaTreeBulkSetterTests.cs +++ b/src/Nethermind/Nethermind.State.Test/PatriciaTreeBulkSetterTests.cs @@ -242,7 +242,7 @@ static byte[] MakeRandomValue(Random rng, bool canBeNull = true) return randData; } - private static List<(Hash256 key, byte[] value)> GenRandomOfLength(int itemCount, int seed = 0) + internal static List<(Hash256 key, byte[] value)> GenRandomOfLength(int itemCount, int seed = 0) { Random rng = new(seed); List<(Hash256 key, byte[] value)> items = []; diff --git a/src/Nethermind/Nethermind.State.Test/PatriciaTrieWitnessGeneratorTests.cs b/src/Nethermind/Nethermind.State.Test/PatriciaTrieWitnessGeneratorTests.cs new file mode 100644 index 000000000000..c68f87332d85 --- /dev/null +++ b/src/Nethermind/Nethermind.State.Test/PatriciaTrieWitnessGeneratorTests.cs @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Linq; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Extensions; +using Nethermind.Core.Test; +using Nethermind.Logging; +using Nethermind.Trie; +using Nethermind.Trie.Pruning; +using NUnit.Framework; + +namespace Nethermind.Store.Test; + +public class PatriciaTrieWitnessGeneratorTests +{ + /// + /// The generator must report exactly the set of pre-state nodes a real execution touches. The ground truth is + /// obtained by replaying the reads and deletions on a clone of the trie through a read-capturing store: every + /// node whose RLP is loaded (path nodes plus the collapse siblings deletion pulls in) is the correct witness. + /// + [TestCaseSource(nameof(WitnessCases))] + public void Witness_matches_capture_during_mutation(Scenario scenario) + { + (TestMemDb db, Hash256 root) = BuildTrie(scenario.Existing); + + HashSet oracle = CaptureDuringMutation(db, root, scenario, out _); + + // The sequential and parallel walks must both produce exactly the oracle's node set. + Assert.That(RunGenerator(db, root, scenario, parallelize: false), Is.EquivalentTo(oracle)); + Assert.That(RunGenerator(db, root, scenario, parallelize: true), Is.EquivalentTo(oracle)); + } + + /// + /// The witness must be self-sufficient: a verifier holding only the generated nodes can serve every read and + /// re-apply every write to recompute the post-state root, without ever hitting a missing node. + /// + [TestCaseSource(nameof(WitnessCases))] + public void Witness_alone_serves_reads_and_writes(Scenario scenario) + { + (TestMemDb db, Hash256 root) = BuildTrie(scenario.Existing); + + // Reference post-state and per-read values from the full trie. + Hash256 expectedRoot = ApplyWrites(new RawScopedTrieStore(db), root, scenario, out Dictionary readValues); + + // Rebuild a store holding ONLY the witness nodes and replay against it. Use the Hash key scheme so nodes + // are addressed purely by keccak, independent of the path they originally sat at. + Dictionary witness = CollectWitness(db, root, scenario); + NodeStorage witnessStorage = new(new TestMemDb(), INodeStorage.KeyScheme.Hash); + foreach ((Hash256AsKey hash, byte[] rlp) in witness) + { + witnessStorage.Set(null, TreePath.Empty, new ValueHash256(hash.Value.Bytes), rlp); + } + + IScopedTrieStore witnessStore = new RawScopedTrieStore(witnessStorage); + PatriciaTree tree = new(witnessStore, LimboLogs.Instance) { RootHash = root }; + + // Reads are served from the witness alone and must match the full trie. + foreach (Hash256 key in scenario.Reads) + { + byte[] value = tree.Get(key.Bytes).ToArray(); + Assert.That(value, Is.EqualTo(readValues[key]), $"read mismatch for {key}"); + } + + // Writes/deletes re-applied against the witness must reproduce the post-state root. + foreach (Hash256 key in scenario.Deletes) tree.Set(key.Bytes, (byte[])null); + foreach ((Hash256 key, byte[] value) in scenario.Writes) tree.Set(key.Bytes, value); + tree.UpdateRootHash(); + + Assert.That(tree.RootHash, Is.EqualTo(expectedRoot)); + } + + private static HashSet RunGenerator(TestMemDb db, Hash256 root, Scenario scenario, bool parallelize) + { + CollectingSink sink = new(); + IScopedTrieStore store = new RawScopedTrieStore(db); + PatriciaTrieWitnessGenerator.Generate(store, root, BuildEntries(scenario), sink, parallelize); + return [.. sink.Nodes.Keys]; + } + + private static Dictionary CollectWitness(TestMemDb db, Hash256 root, Scenario scenario) + { + CollectingSink sink = new(); + IScopedTrieStore store = new RawScopedTrieStore(db); + PatriciaTrieWitnessGenerator.Generate(store, root, BuildEntries(scenario), sink); + return sink.Nodes; + } + + private static PatriciaTrieWitnessGenerator.PathEntry[] BuildEntries(Scenario scenario) + { + // Reads and non-deleting writes both walk their path without triggering a collapse (the generator maps both + // to AccessType.Read); only Delete can collapse a branch. + List entries = []; + foreach (Hash256 key in scenario.Reads) entries.Add(new(key, PatriciaTrieWitnessGenerator.AccessType.Read)); + foreach ((Hash256 key, byte[] _) in scenario.Writes) entries.Add(new(key, PatriciaTrieWitnessGenerator.AccessType.Read)); + foreach (Hash256 key in scenario.Deletes) entries.Add(new(key, PatriciaTrieWitnessGenerator.AccessType.Delete)); + return [.. entries]; + } + + private static HashSet CaptureDuringMutation(TestMemDb db, Hash256 root, Scenario scenario, out Hash256 postRoot) + { + CapturingScopedTrieStore store = new(new RawScopedTrieStore(db)); + postRoot = ApplyWrites(store, root, scenario, out _); + return [.. store.Captured.Keys]; + } + + /// Reads first (on the pristine trie), then applies the mutations, returning the post-state root. + /// + /// Deletes are applied before writes on purpose: the witness must hold for any apply order, and deletes-first is + /// the collapse-maximizing order (a slot emptied by a delete forces the trie to read its sibling before a later + /// write can refill it), so it realizes the worst case the generator must cover. + /// + private static Hash256 ApplyWrites(IScopedTrieStore store, Hash256 root, Scenario scenario, out Dictionary readValues) + { + PatriciaTree tree = new(store, LimboLogs.Instance) { RootHash = root }; + + readValues = []; + foreach (Hash256 key in scenario.Reads) readValues[key] = tree.Get(key.Bytes).ToArray(); + + foreach (Hash256 key in scenario.Deletes) tree.Set(key.Bytes, (byte[])null); + foreach ((Hash256 key, byte[] value) in scenario.Writes) tree.Set(key.Bytes, value); + tree.UpdateRootHash(); + return tree.RootHash; + } + + private static (TestMemDb db, Hash256 root) BuildTrie(List<(Hash256 key, byte[] value)> items) + { + TestMemDb db = new(); + IScopedTrieStore store = new RawScopedTrieStore(db); + PatriciaTree tree = new(store, LimboLogs.Instance) { RootHash = Keccak.EmptyTreeHash }; + foreach ((Hash256 key, byte[] value) in items) tree.Set(key.Bytes, value); + tree.Commit(); + return (db, tree.RootHash); + } + + public sealed class Scenario( + string name, + List<(Hash256 key, byte[] value)> existing, + List reads, + List deletes, + List<(Hash256 key, byte[] value)> writes) + { + public string Name { get; } = name; + public List<(Hash256 key, byte[] value)> Existing { get; } = existing; + public List Reads { get; } = reads; + public List Deletes { get; } = deletes; + public List<(Hash256 key, byte[] value)> Writes { get; } = writes; + public override string ToString() => Name; + } + + public static IEnumerable WitnessCases() + { + // Fuzz: random tries with random read/delete/write subsets plus some absent keys. + for (int seed = 0; seed < 20; seed++) + { + yield return Case(MakeFuzz(seed, 1 + new Random(seed).Next(800))); + } + + // Large tries so a child branch (depth >= 1) also exceeds the parallelization threshold — exercises the + // recursive/nested parallel path and its flipCount + GetSpanOffset span recovery, not just the root fan-out. + yield return Case(MakeFuzz(seed: 101, size: 6000)); + yield return Case(MakeFuzz(seed: 102, size: 12000)); + + // Reuse BulkSet's (existing tree, updates) fixtures — they pack the structural edge cases (replaces, + // removals, extension heads, matching long extensions, splits, ...). The updates become the witness entries: + // a non-empty value is a non-deleting write, a null/empty value a deletion (matching BulkSet's own + // "null/empty == removal" convention). + int idx = 0; + foreach (TestCaseData tc in PatriciaTreeBulkSetterTests.BulkSetTestGen()) + { + List<(Hash256 key, byte[] value)> existing = (List<(Hash256 key, byte[] value)>)tc.Arguments[0]!; + List<(Hash256 key, byte[] value)> updates = (List<(Hash256 key, byte[] value)>)tc.Arguments[1]!; + List<(Hash256 key, byte[] value)> writes = updates.Where(u => u.value is { Length: > 0 }).ToList(); + List deletes = updates.Where(u => u.value is null or { Length: 0 }).Select(u => u.key).ToList(); + yield return Case(new Scenario($"bulkset {idx++}: {tc.TestName}", existing, [], deletes, writes)); + } + + // Targeted collapse: a two-child branch where one child is deleted forces the lone sibling into the witness. + List<(Hash256, byte[])> twoChild = + [ + (Hash("aaaa000000000000000000000000000000000000000000000000000000000000"), Bytes.FromHexString("01")), + (Hash("aaaabbbb00000000000000000000000000000000000000000000000000000000"), Bytes.FromHexString("02")), + ]; + yield return Case(new Scenario("collapse one of two", twoChild, [], [Hash("aaaa000000000000000000000000000000000000000000000000000000000000")], [])); + + // Chained collapse through an extension: deleting the leaf removes the extension chain and collapses the parent. + List<(Hash256, byte[])> chained = + [ + (Hash("1111111111111111111111111111111111111111111111111111111111111111"), Bytes.FromHexString("01")), + (Hash("2222222222222222222222222222222222222222222222222222222222222222"), Bytes.FromHexString("02")), + (Hash("2233333333333333333333333333333333333333333333333333333333333333"), Bytes.FromHexString("03")), + ]; + yield return Case(new Scenario("chained collapse", chained, [], + [ + Hash("2222222222222222222222222222222222222222222222222222222222222222"), + Hash("2233333333333333333333333333333333333333333333333333333333333333"), + ], [])); + + // Absent-key read and absent-key delete (no-op) over a populated trie. + List<(Hash256, byte[])> populated = PatriciaTreeBulkSetterTests.GenRandomOfLength(50, 99); + yield return Case(new Scenario("absent read/delete", populated, + [Hash("dd")], [Hash("ee")], [])); + + // Order-independence: an off-key insert (write branching off a deleted leaf's key) must NOT keep the sibling + // (2b) out of the witness, because applying the delete before the insert transiently collapses the branch. + List<(Hash256, byte[])> splitBase = + [ + (Hash("1a"), Bytes.FromHexString("01")), + (Hash("2b"), Bytes.FromHexString("02")), + ]; + yield return Case(new Scenario("off-key insert still needs sibling", splitBase, + [], [Hash("1a")], [(Hash("1c"), Bytes.FromHexString("03"))])); + + // Insert a new key (split) alongside an update and a delete. + List<(Hash256, byte[])> mixedBase = PatriciaTreeBulkSetterTests.GenRandomOfLength(40, 7); + List mixedPresent = PresentKeys(mixedBase); + yield return Case(new Scenario("mixed read/write/delete", mixedBase, + mixedPresent.Take(5).ToList(), + mixedPresent.Skip(5).Take(5).ToList(), + [(Hash("abcdef0000000000000000000000000000000000000000000000000000000000"), Bytes.FromHexString("ff")), + (mixedPresent[10], Bytes.FromHexString("aa"))])); + + static TestCaseData Case(Scenario s) => new TestCaseData(s).SetName(s.Name); + } + + private static Scenario MakeFuzz(int seed, int size) + { + Random rng = new(seed); + List<(Hash256 key, byte[] value)> existing = PatriciaTreeBulkSetterTests.GenRandomOfLength(size, seed); + List present = PresentKeys(existing); + + List reads = PickSubset(present, rng); + List deletes = PickSubset(present, rng); + + // Sprinkle in absent keys (a disjoint random set). + List<(Hash256 key, byte[] value)> absent = PatriciaTreeBulkSetterTests.GenRandomOfLength(8, ~seed); + for (int i = 0; i < rng.Next(5); i++) reads.Add(absent[i].key); + for (int i = 0; i < rng.Next(5); i++) deletes.Add(absent[4 + i].key); + + return new Scenario($"fuzz {seed} (n={size})", existing, reads, deletes, []); + } + + private static List PickSubset(List from, Random rng) + { + List picked = []; + foreach (Hash256 key in from) + { + if (rng.NextDouble() < 0.3) picked.Add(key); + } + return picked; + } + + private static List PresentKeys(List<(Hash256 key, byte[] value)> items) => + items.Where(it => it.value is { Length: > 0 }).Select(it => it.key).Distinct().ToList(); + + private static Hash256 Hash(string hex) => new(hex.Length >= 64 ? hex[..64] : hex.PadRight(64, '0')); + + private sealed class CollectingSink : PatriciaTrieWitnessGenerator.ISink + { + private readonly object _lock = new(); + public Dictionary Nodes { get; } = []; + + // Locked so the parallel walk can report concurrently. TryAdd enforces the documented contract that each + // standalone node is reported exactly once (so the sink never has to deduplicate). + public void Add(in TreePath path, TrieNode node) + { + byte[] rlp = node.FullRlp.ToArray(); + lock (_lock) + { + Assert.That(Nodes.TryAdd(node.Keccak!, rlp), Is.True, $"node reported more than once: {node.Keccak}"); + } + } + } + + /// Wraps a scoped store and records the RLP of every node whose data is loaded from the backing db. + 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.Taiko.Test/CertainBatchLookupTests.cs b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs index b6e80cd57a11..f5593964829f 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/CertainBatchLookupTests.cs @@ -325,12 +325,13 @@ private static TaikoEngineRpcModule CreateRpcModule(IL1OriginStore l1OriginStore Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For>(), - Substitute.For, IReadOnlyList>>(), + Substitute.For, IReadOnlyList>>(), Substitute.For>>(), Substitute.For?>>(), Substitute.For?>>(), Substitute.For, IReadOnlyList>>(), Substitute.For(), + Substitute.For(), Substitute.For(), specProvider, null!, diff --git a/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs b/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs index 61f21c949ab3..9ade1d6f118c 100644 --- a/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs +++ b/src/Nethermind/Nethermind.Taiko.Test/TxPoolContentListsTests.cs @@ -253,12 +253,13 @@ private static TaikoEngineRpcModule CreateRpcModule( Substitute.For, IReadOnlyList>>(), Substitute.For(), Substitute.For>(), - Substitute.For, IReadOnlyList>>(), + Substitute.For, IReadOnlyList>>(), Substitute.For>>(), Substitute.For?>>(), Substitute.For?>>(), Substitute.For, IReadOnlyList>>(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For(), null!, diff --git a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs index 8c34634ff9ab..f3cb467fd41d 100644 --- a/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs +++ b/src/Nethermind/Nethermind.Taiko/Rpc/TaikoEngineRpcModule.cs @@ -51,6 +51,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa IAsyncHandler?> getBlobsHandlerV4, IHandler, IReadOnlyList> getPayloadBodiesByHashV2Handler, IGetPayloadBodiesByRangeV2Handler getPayloadBodiesByRangeV2Handler, + INewPayloadWithWitnessHandler newPayloadWithWitnessHandler, IEngineRequestsTracker engineRequestsTracker, ISpecProvider specProvider, GCKeeper gcKeeper, @@ -78,6 +79,7 @@ public class TaikoEngineRpcModule(IAsyncHandler getPa getBlobsHandlerV4, getPayloadBodiesByHashV2Handler, getPayloadBodiesByRangeV2Handler, + newPayloadWithWitnessHandler, engineRequestsTracker, specProvider, gcKeeper, diff --git a/src/Nethermind/Nethermind.Trie/PatriciaTrieWitnessGenerator.cs b/src/Nethermind/Nethermind.Trie/PatriciaTrieWitnessGenerator.cs new file mode 100644 index 000000000000..37efcad7adc7 --- /dev/null +++ b/src/Nethermind/Nethermind.Trie/PatriciaTrieWitnessGenerator.cs @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Buffers; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Nethermind.Core.Crypto; +using Nethermind.Core.Threading; +using Nethermind.Trie.Pruning; + +namespace Nethermind.Trie; + +/// +/// Collects the stateless witness for a single trie: given the set of read and written/deleted key paths, it +/// walks the pre-state trie once and reports every node a stateless verifier needs to re-execute the reads and +/// recompute the post-state root. +/// +/// +/// The algorithm mirrors : a single recursive traversal that partial-sorts the +/// entries by nibble, bucketizes them into 16, and recurses — parallelizing a full branch in place exactly as +/// BulkSet does. It is read-only: it never mutates the trie, builds nodes, or commits. Unlike a plain read-path +/// visitor it also reports the collapse sibling: when deletions reduce a branch to a single remaining +/// child the branch collapses into an extension, so the verifier needs that surviving sibling even though it was +/// never on a touched path. +/// +/// The witness is just a set of node RLPs that a verifier rehashes to rebuild the (partial) trie and re-apply the +/// block's changes. For wide compatibility nothing is assumed about the order in which the verifier applies those +/// changes, so the witness must cover every node that any ordering could touch. A recursion therefore +/// returns true ("treat this subtree as deleted", which drives the parent's lone-child check) whenever +/// some permutation of the entries could empty the subtree — not only when the net result deletes it. In +/// particular a keyed node whose key is deleted is treated as deleted even if another entry inserts a sibling that +/// would refill the slot, because the verifier may apply the delete first and transiently collapse the parent. +/// +/// +public static class PatriciaTrieWitnessGenerator +{ + private const int InPlaceSortThreshold = 32; + private const int MinEntriesToParallelizeThreshold = 128; + private const int FullBranch = (1 << TrieNode.BranchesCount) - 1; + + // Per-child verdict. MaybeEmptied: some apply order could empty the child, which arms the parent's + // collapse-sibling capture. Survived / Untraversed: it cannot be emptied under any order. + private const byte Untraversed = 0; + private const byte Survived = 1; + private const byte MaybeEmptied = 2; + + // Deletion is the only structurally significant access, so it is encoded as a null entry value (BulkSet's own + // "null == removal" convention) and everything non-deleting shares this non-null sentinel. + private static readonly byte[] NonDeleteMarker = []; + + /// How a key path was touched in this block. + /// + /// Only is structurally significant — it can empty a slot and collapse a branch, pulling a + /// sibling into the witness. Any non-removing access — a read, an update, or an insert — only needs its path + /// captured and cannot delete a node under any apply order, so they all map to the single . + /// + public enum AccessType : byte + { + /// The key was read or written without being removed; only its path is needed. + Read, + + /// The key was removed. + Delete, + } + + /// A touched key path and how it was accessed in this block. + public readonly struct PathEntry(in ValueHash256 path, AccessType access) + { + /// The full 64-nibble key path (account hash or storage-slot hash). + public readonly ValueHash256 Path = path; + + /// How the key was touched. + public readonly AccessType Access = access; + } + + /// Receives the trie nodes that make up the witness as the generator walks a trie. + /// + /// Only standalone nodes (those with their own ) are reported; inline nodes live + /// inside their parent's RLP and need not be collected separately. Each standalone node is reported once: a trie + /// node is content-addressed, so the same node recurring at two different paths would take a hash collision + /// (astronomically improbable), and the sink therefore need not deduplicate. When the generator runs in parallel, + /// may be called concurrently and must be thread-safe. + /// + public interface ISink + { + /// Reports a witness node at . + /// The trie path at which sits. + /// A resolved, standalone trie node required by the witness. + void Add(in TreePath path, TrieNode node); + } + + /// The two backing arrays the recursion flip-flops between; lets parallel workers recover their span. + private readonly record struct Context(PatriciaTree.BulkSetEntry[] OriginalEntriesArray, PatriciaTree.BulkSetEntry[] OriginalSortBufferArray); + + /// + /// Walks the trie at and reports the witness nodes for to + /// . + /// + /// Resolver for the trie being walked (state trie or a single account's storage trie). + /// Pre-state root of the trie. + /// Every read and written/deleted key path, tagged with its . + /// Receives the witness nodes. Must be thread-safe when is set. + /// When set, a full branch with enough entries fans its 16 children out across threads, recursively (as in BulkSet). + public static void Generate( + ITrieNodeResolver resolver, + Hash256 rootHash, + ReadOnlySpan paths, + ISink sink, + bool parallelize = false) + { + if (paths.Length == 0 || rootHash == Keccak.EmptyTreeHash) return; + + PatriciaTree.BulkSetEntry[] entriesArr = ArrayPool.Shared.Rent(paths.Length); + // BucketSort16 (>= InPlaceSortThreshold entries) needs a separate sort target; a deeper node never has more + // entries than the root, so one root-sized buffer suffices for the whole walk. + PatriciaTree.BulkSetEntry[]? bufferArr = paths.Length >= InPlaceSortThreshold + ? ArrayPool.Shared.Rent(paths.Length) + : null; + try + { + for (int i = 0; i < paths.Length; i++) + { + byte[]? value = paths[i].Access == AccessType.Delete ? null : NonDeleteMarker; + entriesArr[i] = new PatriciaTree.BulkSetEntry(paths[i].Path, value); + } + + TreePath treePath = TreePath.Empty; + TrieNode root = resolver.FindCachedOrUnknown(treePath, rootHash); + + Context ctx = new(entriesArr, bufferArr ?? entriesArr); + Span entries = entriesArr.AsSpan(0, paths.Length); + Span buffer = bufferArr is null ? entries : bufferArr.AsSpan(0, paths.Length); + + Walk(in ctx, resolver, root, ref treePath, entries, buffer, flipCount: 0, parallelize, sink); + } + finally + { + ArrayPool.Shared.Return(entriesArr); + if (bufferArr is not null) ArrayPool.Shared.Return(bufferArr); + } + } + + /// + /// The single recursive traversal (mirrors PatriciaTree.BulkSet). Reports every real node it visits and + /// returns true iff some permutation of the entries could empty the subtree below + /// (see the type remarks). + /// + private static bool Walk( + in Context ctx, + ITrieNodeResolver resolver, + TrieNode node, + ref TreePath path, + Span entries, + Span sortBuffer, + int flipCount, + bool parallelize, + ISink sink) + { + node.ResolveNode(resolver, path); + // Inline nodes (< 32 bytes) have no standalone hash; they live in their parent's already-reported RLP. + if (node.Keccak is not null) sink.Add(path, node); + + if (node.IsLeaf || node.IsExtension) + { + return WalkKeyedNode(in ctx, resolver, node, ref path, entries, sortBuffer, flipCount, parallelize, sink); + } + + // Bucketize by the nibble at this depth. The large path sorts into `sortBuffer` then swaps it with `entries` + // so children read sorted data (BulkSet's flip; `flipCount` parity lets parallel workers recover the array). + Span indexes = stackalloc int[TrieNode.BranchesCount]; + int nibMask; + if (entries.Length == 1) + { + int only = entries[0].GetPathNibble(path.Length); + indexes[only] = 0; + nibMask = 1 << only; + } + else if (entries.Length <= 3) + { + nibMask = PatriciaTree.SortTiny(entries, path.Length, indexes); + } + else if (entries.Length < InPlaceSortThreshold) + { + nibMask = PatriciaTree.InPlaceBucketSort16(entries, path.Length, indexes); + } + else + { + nibMask = PatriciaTree.BucketSort16(entries, sortBuffer, path.Length, indexes); + flipCount++; + Span sorted = sortBuffer; + sortBuffer = entries; + entries = sorted; + } + + Span childState = stackalloc byte[TrieNode.BranchesCount]; + childState.Clear(); + + if (entries.Length >= MinEntriesToParallelizeThreshold && nibMask == FullBranch && parallelize) + { + WalkBranchParallel(in ctx, resolver, node, in path, entries, indexes, flipCount, sink, childState); + } + else + { + TrieNode.ChildIterator childIterator = node.CreateChildIterator(); + path.AppendMut(0); + int mask = nibMask; + while (mask != 0) + { + int nib = BitOperations.TrailingZeroCount(mask); + mask &= mask - 1; + int start = indexes[nib]; + int end = mask != 0 ? indexes[BitOperations.TrailingZeroCount(mask)] : entries.Length; + + path.SetLast(nib); + TrieNode? child = childIterator.GetChildWithChildPath(resolver, ref path, nib); + if (child is null) continue; // absent child: the divergence is already covered by reporting this branch + + bool childMaybeEmptied = Walk(in ctx, resolver, child, ref path, entries[start..end], sortBuffer[start..end], flipCount, parallelize, sink); + childState[nib] = childMaybeEmptied ? MaybeEmptied : Survived; + } + path.TruncateOne(); + } + + return CollapseCheck(resolver, node, ref path, childState, sink); + } + + /// + /// Decides the "treat-as-deleted" answer for a node that carries a key (a leaf or an extension), already resolved + /// and reported. Per the order-independence rule (see the type remarks) it is true if any permutation of + /// the entries could empty the subtree; off-key entries cannot, so only deletions on this node's path matter. + /// + private static bool WalkKeyedNode( + in Context ctx, + ITrieNodeResolver resolver, + TrieNode node, + ref TreePath path, + Span entries, + Span sortBuffer, + int flipCount, + bool parallelize, + ISink sink) + { + TreePath keyedPath = path; + keyedPath.AppendMut(node.Key!); + + if (node.IsLeaf) + { + // An off-key insert cannot save the leaf: the delete may be applied first. + ValueHash256 leafKey = keyedPath.Path; + for (int i = 0; i < entries.Length; i++) + { + if (entries[i].Value is null && entries[i].Path == leafKey) return true; + } + return false; + } + + // Extension: keep the entries within the prefix's subtree [lower, upper]; the rest branch off it and cannot + // empty its child. Extensions are rare, so this stays a plain linear range filter rather than a bucketized + // fan-out like the branch path. + ValueHash256 lower = keyedPath.ToLowerBoundPath(); + ValueHash256 upper = keyedPath.ToUpperBoundPath(); + int m = 0; + for (int i = 0; i < entries.Length; i++) + { + if (entries[i].Path >= lower && entries[i].Path <= upper) entries[m++] = entries[i]; + } + if (m == 0) return false; + + TrieNode? child = node.GetChildWithChildPath(resolver, ref keyedPath, 0); + if (child is null) return false; + return Walk(in ctx, resolver, child, ref keyedPath, entries[..m], sortBuffer[..m], flipCount, parallelize, sink); + } + + /// + /// Parallel form of the branch loop, gated on a full branch with enough entries (as in BulkSet). Children are + /// disjoint subtrees, so each is walked on its own thread; the root branch itself is only read. + /// + private static void WalkBranchParallel( + in Context ctx, + ITrieNodeResolver resolver, + TrieNode node, + in TreePath path, + Span entries, + Span indexes, + int flipCount, + ISink sink, + Span childState) + { + // After `flipCount` flips, `entries` lives in this array and the scratch buffer in the other; recover both so + // each worker can rebuild its span from an offset (a Span cannot cross the Parallel.For boundary). + PatriciaTree.BulkSetEntry[] originalEntries = (flipCount & 1) == 0 ? ctx.OriginalEntriesArray : ctx.OriginalSortBufferArray; + PatriciaTree.BulkSetEntry[] originalBuffer = (flipCount & 1) == 0 ? ctx.OriginalSortBufferArray : ctx.OriginalEntriesArray; + + Job[] jobs = new Job[TrieNode.BranchesCount]; + TrieNode.ChildIterator childIterator = node.CreateChildIterator(); + int mask = FullBranch; + while (mask != 0) + { + int nib = BitOperations.TrailingZeroCount(mask); + mask &= mask - 1; + int start = indexes[nib]; + int end = mask != 0 ? indexes[BitOperations.TrailingZeroCount(mask)] : entries.Length; + Span jobEntries = entries[start..end]; + + TreePath childPath = path.Append(nib); + TrieNode? child = childIterator.GetChildWithChildPath(resolver, ref childPath, nib); + jobs[nib] = new Job(GetSpanOffset(originalEntries, jobEntries), jobEntries.Length, childPath, child); + } + + Context closureCtx = ctx; + Parallel.For(0, TrieNode.BranchesCount, ParallelUnbalancedWork.DefaultOptions, i => + { + TrieNode? child = jobs[i].Child; + int count = jobs[i].Count; + if (child is null || count == 0) return; + + Span e = originalEntries.AsSpan(jobs[i].Start, count); + Span b = originalBuffer.AsSpan(jobs[i].Start, count); + TreePath childPath = jobs[i].ChildPath; + jobs[i].MaybeEmptied = Walk(in closureCtx, resolver, child, ref childPath, e, b, flipCount, parallelize: true, sink); + }); + + for (int nib = 0; nib < TrieNode.BranchesCount; nib++) + { + childState[nib] = jobs[nib].Child is not null && jobs[nib].Count > 0 + ? (jobs[nib].MaybeEmptied ? MaybeEmptied : Survived) + : Untraversed; + } + } + + private struct Job(int start, int count, TreePath childPath, TrieNode? child) + { + public readonly int Start = start; + public readonly int Count = count; + public readonly TreePath ChildPath = childPath; + public readonly TrieNode? Child = child; + public bool MaybeEmptied; + } + + /// + /// After a branch's touched children have been walked, records the lone surviving sibling if the branch may + /// collapse, and reports whether the whole branch may be emptied. + /// + private static bool CollapseCheck( + ITrieNodeResolver resolver, + TrieNode node, + ref TreePath path, + ReadOnlySpan childState, + ISink sink) + { + int survivingCount = 0; + int survivingIndex = -1; + bool survivorTraversed = false; + for (int i = 0; i < TrieNode.BranchesCount; i++) + { + if (childState[i] == MaybeEmptied) continue; + if (childState[i] == Untraversed && node.IsChildNull(i)) continue; + survivingCount++; + if (survivingCount > 1) return false; // >= 2 survivors: the branch cannot collapse + survivingIndex = i; + survivorTraversed = childState[i] == Survived; + } + + if (survivingCount == 0) return true; // every child may be emptied, so the whole branch may be too + + // One survivor, so an order that empties the rest collapses the branch into it. A traversed survivor was + // already reported; an untouched one was not, and the verifier needs it to recompute that collapse. + if (!survivorTraversed) + { + path.AppendMut(survivingIndex); + TrieNode? sibling = node.GetChildWithChildPath(resolver, ref path, survivingIndex); + if (sibling is not null) + { + sibling.ResolveNode(resolver, path); + if (sibling.Keccak is not null) sink.Add(path, sibling); + } + path.TruncateOne(); + } + return false; + } + + private static int GetSpanOffset(T[] array, Span span) + { + ref T spanRef = ref MemoryMarshal.GetReference(span); + ref T arrRef = ref MemoryMarshal.GetArrayDataReference(array); + return (int)(Unsafe.ByteOffset(ref arrRef, ref spanRef) / Unsafe.SizeOf()); + } +}