diff --git a/src/Nethermind/Nethermind.Facade/BlockchainBridge.cs b/src/Nethermind/Nethermind.Facade/BlockchainBridge.cs index ead87d85047d..a02a5fb875a8 100644 --- a/src/Nethermind/Nethermind.Facade/BlockchainBridge.cs +++ b/src/Nethermind/Nethermind.Facade/BlockchainBridge.cs @@ -457,7 +457,8 @@ public Hash256[] GetPendingTransactionFilterChanges(int filterId) => public Address? RecoverTxSender(Transaction tx) => ecdsa.RecoverAddress(tx); - public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock) where TCtx : struct, INodeContext => stateReader.RunTreeVisitor(treeVisitor, baseBlock); + public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext + => stateReader.RunTreeVisitor(treeVisitor, baseBlock, diagnostics: diagnostics); public bool HasStateForBlock(BlockHeader? baseBlock) => stateReader.HasStateForBlock(baseBlock); diff --git a/src/Nethermind/Nethermind.Facade/IBlockchainBridge.cs b/src/Nethermind/Nethermind.Facade/IBlockchainBridge.cs index 9e1ab13e63be..982bd564714e 100644 --- a/src/Nethermind/Nethermind.Facade/IBlockchainBridge.cs +++ b/src/Nethermind/Nethermind.Facade/IBlockchainBridge.cs @@ -51,7 +51,9 @@ public interface IBlockchainBridge : ILogFinder IEnumerable GetLogs(BlockParameter fromBlock, BlockParameter toBlock, HashSet? addresses = null, IEnumerable? topics = null, CancellationToken cancellationToken = default); bool TryGetLogs(int filterId, out IEnumerable filterLogs, CancellationToken cancellationToken = default); - void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock) where TCtx : struct, INodeContext; + /// + void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext; + bool HasStateForBlock(BlockHeader? baseBlock); Witness GenerateExecutionWitness(BlockHeader parent, Block block); diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleMetaTests.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleMetaTests.cs new file mode 100644 index 000000000000..de8afac70fbb --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/Proof/ProofRpcModuleMetaTests.cs @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using Nethermind.Blockchain; +using Nethermind.Blockchain.Find; +using Nethermind.Blockchain.Headers; +using Nethermind.Blockchain.Receipts; +using Nethermind.Config; +using Nethermind.Consensus.Processing; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Core.Test; +using Nethermind.Core.Test.Builders; +using Nethermind.Core.Test.Db; +using Nethermind.Core.Test.Modules; +using Nethermind.Crypto; +using Nethermind.Db; +using Nethermind.Evm.State; +using Nethermind.Int256; +using Nethermind.JsonRpc.Modules; +using Nethermind.JsonRpc.Modules.Proof; +using Nethermind.Logging; +using Nethermind.Specs; +using Nethermind.Specs.Forks; +using Nethermind.State; +using NUnit.Framework; + +namespace Nethermind.JsonRpc.Test.Modules.Proof; + +[Parallelizable(ParallelScope.None)] +public class ProofRpcModuleMetaTests +{ + private IProofRpcModule _proofRpcModule = null!; + private IBlockTree _blockTree = null!; + private IDbProvider _dbProvider = null!; + private TestSpecProvider _specProvider = null!; + private WorldStateManager _worldStateManager = null!; + private IContainer _container = null!; + + [SetUp] + public async Task Setup() + { + _dbProvider = await TestMemDbProvider.InitAsync(); + _worldStateManager = TestWorldStateFactory.CreateWorldStateManagerForTest(_dbProvider, LimboLogs.Instance); + + Hash256 stateRoot; + IWorldState worldState = new WorldState(_worldStateManager.GlobalWorldState, LimboLogs.Instance); + using (System.IDisposable _ = worldState.BeginScope(IWorldState.PreGenesis)) + { + worldState.CreateAccount(TestItem.AddressA, 100_000); + worldState.CreateAccount(TestItem.AddressB, 200_000); + worldState.CreateAccount(TestItem.AddressC, 300_000); + worldState.Commit(London.Instance); + worldState.CommitTree(0); + stateRoot = worldState.StateRoot; + } + + InMemoryReceiptStorage receiptStorage = new(); + _specProvider = new TestSpecProvider(London.Instance); + BlockTreeBuilder blockTreeBuilder = Build.A.BlockTree(new Block(Build.A.BlockHeader.WithStateRoot(stateRoot).TestObject, new BlockBody()), _specProvider) + .WithTransactions(receiptStorage) + .OfChainLength(5); + _blockTree = blockTreeBuilder.TestObject; + + _container = new ContainerBuilder() + .AddModule(new TestNethermindModule(new ConfigProvider())) + .AddSingleton(_specProvider) + .AddSingleton(new CompositeBlockPreprocessorStep(new RecoverSignatures(new EthereumEcdsa(TestBlockchainIds.ChainId), _specProvider, LimboLogs.Instance))) + .AddSingleton(_blockTree) + .AddSingleton(_dbProvider) + .AddSingleton(blockTreeBuilder.HeaderStore) + .AddSingleton(receiptStorage) + .AddSingleton(_worldStateManager) + .Build(); + _proofRpcModule = _container.Resolve>().Create(); + } + + [TearDown] + public void TearDown() => _container.Dispose(); + + [Test] + public void Returns_proof_payload_alongside_meta() + { + ResultWrapper result = _proofRpcModule.proof_getProofWithMeta( + TestItem.AddressA, new HashSet(), BlockParameter.Earliest); + + result.Result.ResultType.Should().Be(ResultType.Success); + AccountProofWithMeta payload = result.Data; + + payload.Should().NotBeNull(); + payload.Proof.Should().NotBeNull(); + payload.Proof.Address.Should().Be(TestItem.AddressA); + payload.Proof.Balance.Should().Be(100_000); + payload.Proof.Proof.Should().NotBeNull(); + payload.Proof.Proof.Length.Should().BeGreaterThan(0); + } + + [Test] + public void Meta_counters_have_sane_values() + { + AccountProofWithMeta payload = _proofRpcModule.proof_getProofWithMeta( + TestItem.AddressA, new HashSet(), BlockParameter.Earliest).Data; + + payload.Meta.Should().NotBeNull(); + payload.Meta.NodeLookups.Should().BeGreaterThan(0, + "every proof generation must perform at least one node lookup"); + payload.Meta.CacheHits.Should().BeGreaterThanOrEqualTo(0); + payload.Meta.CacheHits.Should().BeLessThanOrEqualTo(payload.Meta.NodeLookups, + "cache hits cannot exceed total lookups"); + payload.Meta.MaxDepth.Should().BeGreaterThanOrEqualTo(0); + } + + [Test] + public void Repeated_calls_increase_cache_hits_on_warm_path() + { + AccountProofWithMeta first = _proofRpcModule.proof_getProofWithMeta( + TestItem.AddressA, new HashSet(), BlockParameter.Earliest).Data; + AccountProofWithMeta second = _proofRpcModule.proof_getProofWithMeta( + TestItem.AddressA, new HashSet(), BlockParameter.Earliest).Data; + + second.Meta.CacheHits.Should().BeGreaterThanOrEqualTo(first.Meta.CacheHits, + "warming the cache should not reduce hit count on identical query"); + } + + [Test] + public void Rejects_too_many_storage_keys() + { + HashSet storageKeys = new(); + for (int i = 0; i < 1001; i++) + { + storageKeys.Add((UInt256)i); + } + + ResultWrapper result = _proofRpcModule.proof_getProofWithMeta( + TestItem.AddressA, storageKeys, BlockParameter.Earliest); + + result.Result.ResultType.Should().Be(ResultType.Failure); + result.ErrorCode.Should().Be(ErrorCodes.InvalidParams); + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/AccountProofWithMeta.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/AccountProofWithMeta.cs new file mode 100644 index 000000000000..2486ce9b8862 --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/AccountProofWithMeta.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.State.Proofs; + +namespace Nethermind.JsonRpc.Modules.Proof +{ + /// + /// Response payload of proof_getProofWithMeta — the EIP-1186 account proof plus + /// per-call diagnostics from . + /// + public class AccountProofWithMeta + { + /// EIP-1186 account proof, identical in shape to eth_getProof's result. + public AccountProof Proof { get; set; } = null!; + + /// Per-call diagnostics captured during proof construction. + public ProofMeta Meta { get; set; } = null!; + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/IProofRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/IProofRpcModule.cs index 6a384a687d4c..1485913f651a 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/IProofRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/IProofRpcModule.cs @@ -1,9 +1,12 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections.Generic; using Nethermind.Blockchain.Find; +using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Facade.Eth.RpcTransaction; +using Nethermind.Int256; namespace Nethermind.JsonRpc.Modules.Proof { @@ -28,5 +31,14 @@ public interface IProofRpcModule : IRpcModule ExampleResponse = "{\"receipt\":{\"transactionHash\":\"0xfff473e0d10e9dcc18bb4585fb2ba17f682949996f5dfda41c20c425a53b4e71\",\"transactionIndex\":\"0x0\",\"blockHash\":\"0x539822db4041dac07f02819b1337f5f9d7291a996f80d9c05ada334c7a97264c\",\"blockNumber\":\"0x1\",\"cumulativeGasUsed\":\"0x0\",\"gasUsed\":\"0x0\",\"to\":null,\"contractAddress\":null,\"logs\":[],\"logsBloom\":\"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\",\"status\":\"0x0\",\"type\":\"0x0\"},\"txProof\":[\"0xf851a073ff16e6f3a3ca20ba99ad5bacc973e800ba7ec7092266fcd2520703613e3d9580808080808080a0a70de17dcf5a91c1b986463b4e8419665333b2a66e66f7127baae3d4d58d052d8080808080808080\",\"0xf86530b862f86080018252089400000000000000000000000000000000000000000181801ca0b4e030f395ed357d206b58d9a0ded408589a9e26f1a5b41010772cd0d84b8d16a04d9797a972bc308ea635f22455881c41c7c9fb946c93db6f99d2bd529675af13\"],\"receiptProof\":[\"0xf851a08e4cd3def722e9727e505d3798454165d832e1aabd5c56e5d0e4e9f0796a783280808080808080a05380738598f169c9e407a0f61558e53ea59a4c5e643aabc57679c7c0a3b761428080808080808080\",\"0xf9012f30b9012bf90128a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421825208b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0\"],\"blockHeader\":\"0xf901f9a0b3157bcccab04639f6393042690a6c9862deebe88c781f911e8dfd265531e9ffa01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347940000000000000000000000000000000000000000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0541c8844bd420f79a5f7f8db723e2106160d350043de7cf76d78ea13ed0ff6c9a0e1b1585a222beceb3887dc6701802facccf186c2d0f6aa69e26ae0c431fc2b5db9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000830f424001833d090080830f424183010203a02ba5557a4c62a513c7e56d1bf13373e0da6bec016755483e91589fe1c6d212e28800000000000003e8\"}")] ResultWrapper proof_getTransactionReceipt([JsonRpcParameter(ExampleValue = "[\"0xfff473e0d10e9dcc18bb4585fb2ba17f682949996f5dfda41c20c425a53b4e71\", \"true\"]")] Hash256 txHash, bool includeHeader); + + [JsonRpcMethod(IsImplemented = true, + Description = "Returns the same payload as `eth_getProof` plus per-call diagnostics: `nodeLookups` (total trie-node fetches), `cacheHits` (of those, served from the in-process trie store cache), and `maxDepth` (deepest level reached in the account or any storage trie, in nibbles). Useful as a client-agnostic proxy for the work an EL does to serve a proof. The `proof` field has the same shape and content as `eth_getProof`'s result; duplicate `storageKeys` are deduplicated, matching the existing `eth_getProof` behaviour.", + IsSharable = false, + ExampleResponse = "{\"proof\":{\"address\":\"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2\",\"balance\":\"0x18232098799834c9a9e2b\",\"codeHash\":\"0xd0a06b12ac47863b5c7be4185c2deaad1c61557033f56c7d4ea74429cbb25e23\",\"nonce\":\"0x1\",\"storageHash\":\"0x...\",\"accountProof\":[\"0x...\"],\"storageProof\":[]},\"meta\":{\"nodeLookups\":\"0x6e\",\"cacheHits\":\"0x6e\",\"maxDepth\":8}}")] + ResultWrapper proof_getProofWithMeta( + [JsonRpcParameter(ExampleValue = "\"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\"")] Address accountAddress, + [JsonRpcParameter(ExampleValue = "[]", Description = "Storage keys to include in the proof; duplicates are deduplicated.")] HashSet storageKeys, + [JsonRpcParameter(ExampleValue = "\"latest\"")] BlockParameter? blockParameter); } } diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofMeta.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofMeta.cs new file mode 100644 index 000000000000..73e80a184490 --- /dev/null +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofMeta.cs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.JsonRpc.Modules.Proof +{ + /// + /// Per-call diagnostics returned by proof_getProofWithMeta alongside the EIP-1186 proof. + /// + public class ProofMeta + { + /// + /// Total trie-node fetches the proof construction triggered (account + any storage tries). + /// + public long NodeLookups { get; set; } + + /// + /// Subset of served from the in-process trie store cache. + /// + public long CacheHits { get; set; } + + /// + /// Deepest level the visitor reached in the account or any storage trie, in nibbles. + /// + public int MaxDepth { get; set; } + } +} diff --git a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofRpcModule.cs b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofRpcModule.cs index 9de9a2628c4f..95b298aa0fff 100644 --- a/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofRpcModule.cs +++ b/src/Nethermind/Nethermind.JsonRpc/Modules/Proof/ProofRpcModule.cs @@ -11,15 +11,18 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Specs; using Nethermind.Crypto; +using Nethermind.Facade; using Nethermind.Facade.Eth; using Nethermind.Evm; using Nethermind.Blockchain.Tracing; using Nethermind.Blockchain.Tracing.Proofs; using Nethermind.Facade.Eth.RpcTransaction; +using Nethermind.Int256; using Nethermind.JsonRpc.Data; using Nethermind.Serialization.Rlp; using Nethermind.State.OverridableEnv; using Nethermind.State.Proofs; +using Nethermind.Trie; namespace Nethermind.JsonRpc.Modules.Proof { @@ -31,9 +34,11 @@ public class ProofRpcModule( IBlockFinder blockFinder, IReceiptFinder receiptFinder, ISpecProvider specProvider, - IJsonRpcConfig jsonRpcConfig) + IJsonRpcConfig jsonRpcConfig, + IBlockchainBridge blockchainBridge) : IProofRpcModule { + private const int GetProofWithMetaStorageKeyLimit = 1000; private readonly HeaderDecoder _headerDecoder = new(); private static readonly IRlpStreamEncoder _receiptEncoder = Rlp.GetStreamEncoder(); @@ -175,6 +180,46 @@ public ResultWrapper proof_getTransactionReceipt(Hash256 txHas return ResultWrapper.Success(receiptWithProof); } + public ResultWrapper proof_getProofWithMeta(Address accountAddress, HashSet storageKeys, BlockParameter? blockParameter) + { + if (storageKeys.Count > GetProofWithMetaStorageKeyLimit) + { + return ResultWrapper.Fail( + $"storageKeys: {storageKeys.Count} is over the query limit {GetProofWithMetaStorageKeyLimit}.", + ErrorCodes.InvalidParams); + } + + SearchResult searchResult = blockFinder.SearchForHeader(blockParameter); + if (searchResult.IsError) + { + return ResultWrapper.Fail(searchResult); + } + + BlockHeader header = searchResult.Object; + + if (!blockchainBridge.HasStateForBlock(header!)) + { + return ResultWrapper.Fail( + $"No state available for block {header!.ToString(BlockHeader.Format.Short)}", + ErrorCodes.ResourceUnavailable); + } + + AccountProofCollector accountProofCollector = new(accountAddress, storageKeys); + ProofDiagnostics diagnostics = new(); + blockchainBridge.RunTreeVisitor(accountProofCollector, header!, diagnostics: diagnostics); + + return ResultWrapper.Success(new AccountProofWithMeta + { + Proof = accountProofCollector.BuildResult(), + Meta = new ProofMeta + { + NodeLookups = diagnostics.NodeLookups, + CacheHits = diagnostics.CacheHits, + MaxDepth = diagnostics.MaxDepth, + }, + }); + } + private AccountProof[] CollectAccountProofs(ITracer tracer, BlockHeader? baseBlock, ProofTxTracer proofTxTracer) { List accountProofs = new(); diff --git a/src/Nethermind/Nethermind.State.Flat/FlatStateReader.cs b/src/Nethermind/Nethermind.State.Flat/FlatStateReader.cs index 33c8d7495f1d..c5d3a679b0a5 100644 --- a/src/Nethermind/Nethermind.State.Flat/FlatStateReader.cs +++ b/src/Nethermind/Nethermind.State.Flat/FlatStateReader.cs @@ -52,7 +52,7 @@ public ReadOnlySpan GetStorage(BlockHeader? baseBlock, Address address, in public byte[]? GetCode(in ValueHash256 codeHash) => codeHash == Keccak.OfAnEmptyString.ValueHash256 ? [] : codeDb[codeHash.Bytes]; - public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null) where TCtx : struct, INodeContext + public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext { StateId stateId = new(baseBlock); @@ -62,7 +62,7 @@ public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? ba ReadOnlyStateTrieStoreAdapter trieStoreAdapter = new(reader); PatriciaTree patriciaTree = new(trieStoreAdapter, logManager); - patriciaTree.Accept(treeVisitor, stateId.StateRoot.ToCommitment(), visitingOptions); + patriciaTree.Accept(treeVisitor, stateId.StateRoot.ToCommitment(), visitingOptions, diagnostics: diagnostics); } public bool HasStateForBlock(BlockHeader? baseBlock) => flatDbManager.HasStateForBlock(new StateId(baseBlock)); diff --git a/src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatOverridableWorldScope.cs b/src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatOverridableWorldScope.cs index 7c63a8702ff8..e17f47c6ede4 100644 --- a/src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatOverridableWorldScope.cs +++ b/src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatOverridableWorldScope.cs @@ -174,7 +174,7 @@ public ReadOnlySpan GetStorage(BlockHeader? baseBlock, Address address, in public byte[]? GetCode(in ValueHash256 codeHash) => codeHash == ValueKeccak.OfAnEmptyString ? [] : overridableWorldScope._codeDbOverlay[codeHash.Bytes]; - public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null) where TCtx : struct, INodeContext + public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext { StateId stateId = new(baseBlock); using SnapshotBundle snapshotBundle = overridableWorldScope.GatherSnapshotBundle(baseBlock); @@ -183,7 +183,7 @@ public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? ba StateTrieStoreAdapter trieStoreAdapter = new(snapshotBundle, concurrency); PatriciaTree patriciaTree = new(trieStoreAdapter, LimboLogs.Instance); - patriciaTree.Accept(treeVisitor, stateId.StateRoot.ToCommitment(), visitingOptions); + patriciaTree.Accept(treeVisitor, stateId.StateRoot.ToCommitment(), visitingOptions, diagnostics: diagnostics); } public bool HasStateForBlock(BlockHeader? baseBlock) => overridableWorldScope.HasStateForBlock(baseBlock); diff --git a/src/Nethermind/Nethermind.State/IStateReader.cs b/src/Nethermind/Nethermind.State/IStateReader.cs index f52f5f9b5047..2ff2588b998b 100644 --- a/src/Nethermind/Nethermind.State/IStateReader.cs +++ b/src/Nethermind/Nethermind.State/IStateReader.cs @@ -15,7 +15,14 @@ public interface IStateReader ReadOnlySpan GetStorage(BlockHeader? baseBlock, Address address, in UInt256 index); byte[]? GetCode(Hash256 codeHash); byte[]? GetCode(in ValueHash256 codeHash); - void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null) where TCtx : struct, INodeContext; + /// + /// Run a tree visitor against the state at . When + /// is non-null, the resolver is wrapped with metering and + /// per-call lookup, cache-miss, and depth counters are accumulated into it (used by + /// proof_getProofWithMeta). + /// + void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? baseBlock, VisitingOptions? visitingOptions = null, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext; + bool HasStateForBlock(BlockHeader? baseBlock); } } diff --git a/src/Nethermind/Nethermind.State/StateReader.cs b/src/Nethermind/Nethermind.State/StateReader.cs index f9ca5413f991..3f7626f3fa6a 100644 --- a/src/Nethermind/Nethermind.State/StateReader.cs +++ b/src/Nethermind/Nethermind.State/StateReader.cs @@ -40,7 +40,8 @@ public ReadOnlySpan GetStorage(BlockHeader? baseBlock, Address address, in public byte[]? GetCode(Hash256 codeHash) => codeHash == Keccak.OfAnEmptyString ? [] : _codeDb[codeHash.Bytes]; - public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? header, VisitingOptions? visitingOptions = null) where TCtx : struct, INodeContext => _state.Accept(treeVisitor, header?.StateRoot ?? Keccak.EmptyTreeHash, visitingOptions); + public void RunTreeVisitor(ITreeVisitor treeVisitor, BlockHeader? header, VisitingOptions? visitingOptions = null, ProofDiagnostics? diagnostics = null) where TCtx : struct, INodeContext + => _state.Accept(treeVisitor, header?.StateRoot ?? Keccak.EmptyTreeHash, visitingOptions, diagnostics: diagnostics); public bool HasStateForBlock(BlockHeader? baseBlock) => trieStore.HasRoot(baseBlock?.StateRoot ?? Keccak.EmptyTreeHash, baseBlock?.Number ?? 0); diff --git a/src/Nethermind/Nethermind.Trie.Test/MeteredTrieNodeResolverTests.cs b/src/Nethermind/Nethermind.Trie.Test/MeteredTrieNodeResolverTests.cs new file mode 100644 index 000000000000..18dc5d5adff2 --- /dev/null +++ b/src/Nethermind/Nethermind.Trie.Test/MeteredTrieNodeResolverTests.cs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using FluentAssertions; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Trie.Pruning; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Trie.Test; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class MeteredTrieNodeResolverTests +{ + [Test] + public void FindCachedOrUnknown_increments_node_lookups_and_observes_depth() + { + ITrieNodeResolver inner = Substitute.For(); + TrieNode dummy = new(NodeType.Unknown, TestItem.KeccakA); + inner.FindCachedOrUnknown(default, default!).ReturnsForAnyArgs(dummy); + + ProofDiagnostics diag = new(); + MeteredTrieNodeResolver resolver = new(inner, diag); + + TreePath path = TreePath.Empty; + resolver.FindCachedOrUnknown(path, TestItem.KeccakA); + TreePath deeper = TreePath.FromNibble(stackalloc byte[] { 1, 2, 3, 4, 5 }); + resolver.FindCachedOrUnknown(deeper, TestItem.KeccakB); + + diag.NodeLookups.Should().Be(2); + diag.MaxDepth.Should().Be(5); + } + + [Test] + public void LoadRlp_increments_cache_misses_so_cache_hits_are_lookups_minus_loads() + { + ITrieNodeResolver inner = Substitute.For(); + TrieNode dummy = new(NodeType.Unknown, TestItem.KeccakA); + inner.FindCachedOrUnknown(default, default!).ReturnsForAnyArgs(dummy); + inner.LoadRlp(default, default!).ReturnsForAnyArgs(new byte[] { 0xc0 }); + + ProofDiagnostics diag = new(); + MeteredTrieNodeResolver resolver = new(inner, diag); + + TreePath path = TreePath.Empty; + resolver.FindCachedOrUnknown(path, TestItem.KeccakA); + resolver.FindCachedOrUnknown(path, TestItem.KeccakA); + resolver.LoadRlp(path, TestItem.KeccakA); + + diag.NodeLookups.Should().Be(2); + diag.CacheMisses.Should().Be(1); + diag.CacheHits.Should().Be(1); + } + + [Test] + public void GetStorageTrieNodeResolver_returns_metered_wrapper_sharing_same_diagnostics() + { + ITrieNodeResolver inner = Substitute.For(); + ITrieNodeResolver storageInner = Substitute.For(); + TrieNode dummy = new(NodeType.Unknown, TestItem.KeccakA); + inner.GetStorageTrieNodeResolver(Arg.Any()).Returns(storageInner); + storageInner.FindCachedOrUnknown(default, default!).ReturnsForAnyArgs(dummy); + + ProofDiagnostics diag = new(); + MeteredTrieNodeResolver resolver = new(inner, diag); + + ITrieNodeResolver storageResolver = resolver.GetStorageTrieNodeResolver(Keccak.Zero); + storageResolver.Should().BeOfType(); + storageResolver.FindCachedOrUnknown(TreePath.Empty, TestItem.KeccakA); + + diag.NodeLookups.Should().Be(1, "the wrapped storage resolver shares the original diagnostics object"); + } +} diff --git a/src/Nethermind/Nethermind.Trie/MeteredTrieNodeResolver.cs b/src/Nethermind/Nethermind.Trie/MeteredTrieNodeResolver.cs new file mode 100644 index 000000000000..bcb91314bbe0 --- /dev/null +++ b/src/Nethermind/Nethermind.Trie/MeteredTrieNodeResolver.cs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Trie.Pruning; + +namespace Nethermind.Trie +{ + internal sealed class MeteredTrieNodeResolver(ITrieNodeResolver inner, ProofDiagnostics diagnostics) : ITrieNodeResolver + { + public TrieNode FindCachedOrUnknown(in TreePath path, Hash256 hash) + { + diagnostics.RecordLookup(); + diagnostics.ObserveDepth(path.Length); + return inner.FindCachedOrUnknown(path, hash); + } + + public byte[]? LoadRlp(in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + { + diagnostics.RecordCacheMiss(); + return inner.LoadRlp(path, hash, flags); + } + + public byte[]? TryLoadRlp(in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + { + diagnostics.RecordCacheMiss(); + return inner.TryLoadRlp(path, hash, flags); + } + + public ITrieNodeResolver GetStorageTrieNodeResolver(Hash256? address) => + new MeteredTrieNodeResolver(inner.GetStorageTrieNodeResolver(address), diagnostics); + + public INodeStorage.KeyScheme Scheme => inner.Scheme; + } +} diff --git a/src/Nethermind/Nethermind.Trie/PatriciaTree.cs b/src/Nethermind/Nethermind.Trie/PatriciaTree.cs index f1a8a201a830..e44cbbc42e89 100644 --- a/src/Nethermind/Nethermind.Trie/PatriciaTree.cs +++ b/src/Nethermind/Nethermind.Trie/PatriciaTree.cs @@ -1034,20 +1034,23 @@ private void DoWarmUpPath(Span remainingKey, ref TreePath path, TrieNode? } /// - /// Run tree visitor + /// Run tree visitor. /// /// The visitor /// State root hash (not storage root) /// Options - /// Address of storage, if it should visit storage. + /// Address of storage, if it should visit storage. /// Root of storage if it should visit storage. Optional for performance. + /// When non-null, the resolver is wrapped with + /// and per-call lookup, cache-miss, and depth counters are accumulated into this instance. /// public void Accept( ITreeVisitor visitor, Hash256 rootHash, VisitingOptions? visitingOptions = null, Hash256? storageAddr = null, - Hash256? storageRoot = null + Hash256? storageRoot = null, + ProofDiagnostics? diagnostics = null ) where TNodeContext : struct, INodeContext { ArgumentNullException.ThrowIfNull(visitor); @@ -1096,6 +1099,11 @@ Hash256 DecodeStorageRoot(Hash256 root, Hash256 address) resolver = resolver.GetStorageTrieNodeResolver(storageAddr); } + if (diagnostics is not null) + { + resolver = new MeteredTrieNodeResolver(resolver, diagnostics); + } + bool TryGetRootRef(out TrieNode? rootRef) { rootRef = null; diff --git a/src/Nethermind/Nethermind.Trie/ProofDiagnostics.cs b/src/Nethermind/Nethermind.Trie/ProofDiagnostics.cs new file mode 100644 index 000000000000..f95fea6d3204 --- /dev/null +++ b/src/Nethermind/Nethermind.Trie/ProofDiagnostics.cs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Threading; + +namespace Nethermind.Trie +{ + /// + /// Per-call counters populated by while a single + /// traversal runs. Values are aggregate + /// across the account and any storage tries visited during that traversal. + /// + /// + /// Counter mutations use so the same instance is safe to share + /// across multiple threads when the visitor runs with MaxDegreeOfParallelism > 1 + /// (e.g. the BatchedTrieVisitor path). For the default proof-RPC code path the + /// traversal is single-threaded and the atomic ops are uncontended. + /// + public sealed class ProofDiagnostics + { + private long _nodeLookups; + private long _cacheMisses; + private int _maxDepth; + + /// + /// Total number of FindCachedOrUnknown calls observed by the metered resolver + /// during the traversal — i.e. one per visited trie node. + /// + public long NodeLookups => Interlocked.Read(ref _nodeLookups); + + /// + /// Number of LoadRlp / TryLoadRlp calls observed — i.e. node fetches that + /// missed the in-process trie store cache and required reading from the underlying store. + /// + public long CacheMisses => Interlocked.Read(ref _cacheMisses); + + /// + /// Deepest (in nibbles) reached during the traversal across + /// the account or any storage trie. + /// + public int MaxDepth => Volatile.Read(ref _maxDepth); + + /// + /// Derived: minus , clamped at zero. + /// + /// + /// The clamp guards a pathological case where a caller invokes LoadRlp without a + /// preceding FindCachedOrUnknown; the proof code path always pairs them, so under + /// normal use the subtraction never goes negative. + /// + public long CacheHits => Math.Max(0, NodeLookups - CacheMisses); + + /// Increment by one. Thread-safe. + public void RecordLookup() => Interlocked.Increment(ref _nodeLookups); + + /// Increment by one. Thread-safe. + public void RecordCacheMiss() => Interlocked.Increment(ref _cacheMisses); + + /// + /// Update to if it exceeds the current value. + /// Thread-safe (CAS loop). + /// + public void ObserveDepth(int level) + { + int current = _maxDepth; + while (level > current) + { + int observed = Interlocked.CompareExchange(ref _maxDepth, level, current); + if (observed == current) return; + current = observed; + } + } + } +}