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