diff --git a/src/Nethermind/Nethermind.Blockchain/Tracing/IntermediateRootsBlockTracer.cs b/src/Nethermind/Nethermind.Blockchain/Tracing/IntermediateRootsBlockTracer.cs new file mode 100644 index 000000000000..1c4224effa72 --- /dev/null +++ b/src/Nethermind/Nethermind.Blockchain/Tracing/IntermediateRootsBlockTracer.cs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Evm.State; +using Nethermind.Evm.Tracing; +using Nethermind.Evm.TransactionProcessing; + +namespace Nethermind.Blockchain.Tracing; + +/// +/// Captures the world state root after each transaction in a block. +/// Mirrors geth's debug_intermediateRoots behaviour. +/// +/// +/// Roots are produced per transaction in execution order; a failed transaction still +/// produces its post-execution root (matching geth's partial-result semantics). +/// System-level calls (EIP-4788, EIP-2935) and withdrawals do not produce entries +/// because only dispatches on user transactions. +/// +public class IntermediateRootsBlockTracer(IWorldState worldState, IReleaseSpec spec) + : BlockTracerBase +{ + protected override IntermediateRootsTxTracer OnStart(Transaction? tx) => new(worldState, spec); + + protected override Hash256 OnEnd(IntermediateRootsTxTracer txTracer) => txTracer.StateRoot; + + public class IntermediateRootsTxTracer(IWorldState worldState, IReleaseSpec spec) : TxTracer + { + public override bool IsTracingReceipt => true; + + public Hash256 StateRoot { get; private set; } + + public override void MarkAsSuccess(Address recipient, in GasConsumed gasSpent, byte[] output, LogEntry[] logs, Hash256? stateRoot = null) => + Capture(stateRoot); + + public override void MarkAsFailed(Address recipient, in GasConsumed gasSpent, byte[] output, string? error, Hash256? stateRoot = null) => + Capture(stateRoot); + + private void Capture(Hash256? reportedStateRoot) + { + if (reportedStateRoot is not null) + { + StateRoot = reportedStateRoot; + return; + } + + // Post-Byzantium (EIP-658): TransactionProcessor commits with commitRoots=false, + // which buffers changes into _blockChanges without flushing them to the trie. + // Force a flush so the recomputed root reflects this transaction's effect — the + // BlockProcessor's later end-of-block commit is idempotent for already-flushed entries. + worldState.Commit(spec, commitRoots: true); + worldState.RecalculateStateRoot(); + StateRoot = worldState.StateRoot; + } + } +} diff --git a/src/Nethermind/Nethermind.Consensus/Tracing/GethStyleTracer.cs b/src/Nethermind/Nethermind.Consensus/Tracing/GethStyleTracer.cs index 8f0a56216f6a..b84f0c00c7bc 100644 --- a/src/Nethermind/Nethermind.Consensus/Tracing/GethStyleTracer.cs +++ b/src/Nethermind/Nethermind.Consensus/Tracing/GethStyleTracer.cs @@ -19,6 +19,7 @@ using Nethermind.Evm.State; using Nethermind.State.OverridableEnv; using Nethermind.Evm.Tracing; +using Nethermind.Blockchain.Tracing; using Nethermind.Blockchain.Tracing.GethStyle; using Nethermind.Blockchain.Tracing.GethStyle.Custom.JavaScript; using Nethermind.Blockchain.Tracing.GethStyle.Custom.Native; @@ -123,6 +124,34 @@ public IReadOnlyCollection TraceBlock(BlockParameter blockParam public IReadOnlyCollection TraceBlock(Block block, GethTraceOptions options, CancellationToken cancellationToken) => TraceBlockImpl(block, options, cancellationToken); + public IReadOnlyCollection TraceBlockIntermediateRoots(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(blockHash); + ArgumentNullException.ThrowIfNull(options); + + // Mirror geth: canonical blocks first, fall back to the bad-block store so the diagnostic + // use case (replaying a rejected block to find divergence) works. + Block block = blockTree.FindBlock(blockHash) + ?? badBlockStore.GetAll().FirstOrDefault(b => b.Hash == blockHash) + ?? throw new InvalidOperationException($"Cannot find block {blockHash}"); + if (block.IsGenesis) throw new InvalidOperationException("genesis is not traceable"); + + BlockHeader? parent = FindParent(block); + + using Scope scope = blockProcessingEnv.BuildAndOverride(parent, options.StateOverrides); + IntermediateRootsBlockTracer tracer = new(scope.Component.WorldState, specProvider.GetSpec(block.Header)); + try + { + scope.Component.BlockchainProcessor.Process(block, ProcessingOptions.Trace, tracer.WithCancellation(cancellationToken), cancellationToken); + return tracer.BuildResult(); + } + catch + { + tracer.TryDispose(); + throw; + } + } + public IEnumerable TraceBlockToFile(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(blockHash); diff --git a/src/Nethermind/Nethermind.Consensus/Tracing/IGethStyleTracer.cs b/src/Nethermind/Nethermind.Consensus/Tracing/IGethStyleTracer.cs index 634d69ea6503..51a8a44364a3 100644 --- a/src/Nethermind/Nethermind.Consensus/Tracing/IGethStyleTracer.cs +++ b/src/Nethermind/Nethermind.Consensus/Tracing/IGethStyleTracer.cs @@ -23,6 +23,15 @@ public interface IGethStyleTracer IReadOnlyCollection TraceBlock(BlockParameter blockParameter, GethTraceOptions options, CancellationToken cancellationToken); IReadOnlyCollection TraceBlock(Rlp blockRlp, GethTraceOptions options, CancellationToken cancellationToken); IReadOnlyCollection TraceBlock(Block block, GethTraceOptions options, CancellationToken cancellationToken); + /// + /// Replays and returns the world-state root after each transaction + /// in execution order. EIP-4788/EIP-2935 system calls and withdrawals do not produce an entry. + /// + /// Hash of a canonical or bad-stored block to replay. + /// Trace options (state overrides are applied to the parent base state). + /// Cooperative cancellation. + /// Post-tx state roots in execution order; failed transactions still produce a root. + IReadOnlyCollection TraceBlockIntermediateRoots(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken); IEnumerable TraceBlockToFile(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken); IEnumerable TraceBadBlockToFile(Hash256 blockHash, GethTraceOptions options, CancellationToken cancellationToken); } diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugModuleTests.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugModuleTests.cs index f37c26d1635d..92251c657ede 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugModuleTests.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugModuleTests.cs @@ -419,4 +419,53 @@ public void StandardTraceBlockToFile_returns_error_when_state_unavailable(bool i actual.ErrorCode.Should().Be(ErrorCodes.ResourceUnavailable); actual.Result.Error.Should().Contain("No state available"); } + + [Test] + public void Debug_intermediateRoots_returns_post_tx_roots_from_bridge() + { + Hash256 blockHash = TestItem.KeccakA; + BlockHeader header = Build.A.BlockHeader.WithNumber(1).TestObject; + _blockFinder.FindHeader(blockHash).Returns(header); + _blockchainBridge.HasStateForBlock(Arg.Is(header)).Returns(true); + + Hash256[] expected = [TestItem.KeccakB, TestItem.KeccakC]; + _debugBridge + .GetBlockIntermediateRoots(Arg.Is(blockHash), Arg.Any(), Arg.Any()) + .Returns(expected); + + DebugRpcModule rpcModule = CreateDebugRpcModule(_debugBridge); + ResultWrapper> actual = rpcModule.debug_intermediateRoots(blockHash); + + actual.Result.ResultType.Should().Be(ResultType.Success); + actual.Data.Should().BeEquivalentTo(expected); + } + + [Test] + public void Debug_intermediateRoots_fails_when_state_unavailable() + { + Hash256 blockHash = TestItem.KeccakA; + BlockHeader header = Build.A.BlockHeader.WithNumber(1).TestObject; + _blockFinder.FindHeader(blockHash).Returns(header); + _blockchainBridge.HasStateForBlock(Arg.Is(header)).Returns(false); + + DebugRpcModule rpcModule = CreateDebugRpcModule(_debugBridge); + ResultWrapper> actual = rpcModule.debug_intermediateRoots(blockHash); + + actual.Result.ResultType.Should().Be(ResultType.Failure); + actual.ErrorCode.Should().Be(ErrorCodes.ResourceUnavailable); + } + + [Test] + public void Debug_intermediateRoots_fails_when_block_not_found() + { + Hash256 blockHash = TestItem.KeccakA; + _blockFinder.FindHeader(blockHash).ReturnsNull(); + + DebugRpcModule rpcModule = CreateDebugRpcModule(_debugBridge); + ResultWrapper> actual = rpcModule.debug_intermediateRoots(blockHash); + + actual.Result.ResultType.Should().Be(ResultType.Failure); + actual.ErrorCode.Should().Be(ErrorCodes.ResourceNotFound); + actual.Result.Error.Should().Contain("Cannot find header"); + } } diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugRpcModuleTests.IntermediateRoots.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugRpcModuleTests.IntermediateRoots.cs new file mode 100644 index 000000000000..baefcec8a6c5 --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/DebugRpcModuleTests.IntermediateRoots.cs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using NUnit.Framework; + +namespace Nethermind.JsonRpc.Test.Modules; + +public partial class DebugRpcModuleTests +{ + [Test] + public async Task Debug_intermediateRoots_returns_one_root_per_transaction() + { + using Context context = await Context.Create(); + await context.Blockchain.AddBlock(CreateTraceBlockTransactions(context.Blockchain)); + + Hash256 blockHash = context.Blockchain.BlockTree.Head!.Hash!; + ResultWrapper> result = context.DebugRpcModule.debug_intermediateRoots(blockHash); + + result.Result.ResultType.Should().Be(ResultType.Success); + result.Data.Should().HaveCount(2, "the block has exactly two user transactions"); + result.Data.Should().OnlyHaveUniqueItems("each tx mutates state, producing a distinct post-tx root"); + result.Data.Should().NotContain(Keccak.Zero); + } + + [Test] + public async Task Debug_intermediateRoots_rejects_genesis() + { + using Context context = await Context.Create(); + Hash256 genesisHash = context.Blockchain.BlockTree.Genesis!.Hash!; + + Func>> act = + () => context.DebugRpcModule.debug_intermediateRoots(genesisHash); + + act.Should().Throw().WithMessage("*genesis*"); + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugBridge.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugBridge.cs index a964d3731191..ff4ccbfc2991 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugBridge.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugBridge.cs @@ -166,6 +166,9 @@ public IReadOnlyCollection GetBlockTrace(Rlp blockRlp, Cancella public IReadOnlyCollection GetBlockTrace(Block block, CancellationToken cancellationToken, GethTraceOptions? gethTraceOptions = null) => _tracer.TraceBlock(block, gethTraceOptions ?? GethTraceOptions.Default, cancellationToken); + public IReadOnlyCollection GetBlockIntermediateRoots(Hash256 blockHash, CancellationToken cancellationToken, GethTraceOptions? gethTraceOptions = null) => + _tracer.TraceBlockIntermediateRoots(blockHash, gethTraceOptions ?? GethTraceOptions.Default, cancellationToken); + public byte[]? GetBlockRlp(BlockParameter parameter) { if (parameter.BlockNumber is long number) diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugRpcModule.cs index e5903d4c4e92..1886e5f6a0e0 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/DebugRpcModule.cs @@ -298,6 +298,24 @@ public ResultWrapper> debug_traceBlockByHas } } + public ResultWrapper> debug_intermediateRoots(Hash256 blockHash, GethTraceOptions? options = null) + { + TryGetHeaderAndCheckState>(blockHash, out ResultWrapper>? headerError); + if (headerError is not null) + { + return headerError; + } + + using CancellationTokenSource? timeout = BuildTimeoutCancellationTokenSource(); + CancellationToken cancellationToken = timeout.Token; + + IReadOnlyCollection roots = debugBridge.GetBlockIntermediateRoots(blockHash, cancellationToken, options); + + if (_logger.IsTrace) _logger.Trace($"{nameof(debug_intermediateRoots)} request {blockHash}, roots: {roots.Count}"); + + return ResultWrapper>.Success(roots); + } + public ResultWrapper debug_traceBlockFromFile(string fileName, GethTraceOptions options = null) => throw new NotImplementedException(); public ResultWrapper debug_dumpBlock(BlockParameter blockParameter) => throw new NotImplementedException(); diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugBridge.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugBridge.cs index 00784e842e71..d1705cb20260 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugBridge.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugBridge.cs @@ -24,6 +24,7 @@ public interface IDebugBridge IReadOnlyCollection GetBlockTrace(BlockParameter blockParameter, CancellationToken cancellationToken, GethTraceOptions gethTraceOptions = null); IReadOnlyCollection GetBlockTrace(Rlp blockRlp, CancellationToken cancellationToken, GethTraceOptions? gethTraceOptions = null); IReadOnlyCollection GetBlockTrace(Block block, CancellationToken cancellationToken, GethTraceOptions? gethTraceOptions = null); + IReadOnlyCollection GetBlockIntermediateRoots(Hash256 blockHash, CancellationToken cancellationToken, GethTraceOptions? gethTraceOptions = null); Block? GetBlock(BlockParameter param); byte[] GetBlockRlp(BlockParameter param); byte[] GetDbValue(string dbName, byte[] key); diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugRpcModule.cs index ed9f3911f18d..387a23db6dd9 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/DebugModule/IDebugRpcModule.cs @@ -50,6 +50,9 @@ public interface IDebugRpcModule : IRpcModule [JsonRpcMethod(Description = "Similar to debug_traceBlock, this method accepts a block hash and replays the block that is already present in the database.", IsImplemented = true, IsSharable = false)] ResultWrapper> debug_traceBlockByHash(Hash256 blockHash, GethTraceOptions options = null); + [JsonRpcMethod(Description = "Replays a block and returns the world-state root after each transaction in execution order. EIP-4788/EIP-2935 system calls and withdrawals do not produce an entry. Failed transactions still produce a root (geth partial-result semantics). The block must be canonical or in the bad-block store.", IsImplemented = true, IsSharable = false)] + ResultWrapper> debug_intermediateRoots(Hash256 blockHash, GethTraceOptions? options = null); + [JsonRpcMethod(Description = "", IsImplemented = false, IsSharable = false)] ResultWrapper debug_traceBlockFromFile(string fileName, GethTraceOptions options = null);