diff --git a/src/Nethermind b/src/Nethermind index 40aa1f345..483dbf664 160000 --- a/src/Nethermind +++ b/src/Nethermind @@ -1 +1 @@ -Subproject commit 40aa1f345281bdffc597154eaa223ffe65634d23 +Subproject commit 483dbf6647865181ad95a02a48e2dfbeadc3f082 diff --git a/src/Nethermind.Arbitrum.Test/Execution/Stateless/ArbitrumWitnessGenerationTests.cs b/src/Nethermind.Arbitrum.Test/Execution/Stateless/ArbitrumWitnessGenerationTests.cs index 4dbc80498..1f1be777e 100644 --- a/src/Nethermind.Arbitrum.Test/Execution/Stateless/ArbitrumWitnessGenerationTests.cs +++ b/src/Nethermind.Arbitrum.Test/Execution/Stateless/ArbitrumWitnessGenerationTests.cs @@ -36,7 +36,8 @@ public async Task RecordBlockCreation_WitnessWithoutUserWasms_StatelessExecution using ArbitrumRpcTestBlockchain chain = new ArbitrumTestBlockchainBuilder() .WithRecording(recording) - .Build(); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + .Build(chain => chain.WorldStateManager.FlushCache(CancellationToken.None)); ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation(new RecordBlockCreationParameters(digestMessage.Index, digestMessage.Message, WasmTargets: [])); RecordResult recordResult = ThrowOnFailure(recordResultWrapper, digestMessage.Index); @@ -74,7 +75,8 @@ public async Task RecordBlockCreation_WitnessWithUserWasms_StatelessExecutionIsS using ArbitrumRpcTestBlockchain chain = new ArbitrumTestBlockchainBuilder() .WithRecording(recording) - .Build(); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + .Build(chain => chain.WorldStateManager.FlushCache(CancellationToken.None)); string[] wasmTargets = chain.StylusTargetConfig.GetWasmTargets().ToArray(); ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation(new RecordBlockCreationParameters(digestMessage.Index, digestMessage.Message, WasmTargets: wasmTargets)); @@ -112,7 +114,8 @@ public async Task RecordBlockCreation_WitnessWithUserWasms_CaptureAsms(ulong mes using ArbitrumRpcTestBlockchain chain = new ArbitrumTestBlockchainBuilder() .WithRecording(recording) - .Build(); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + .Build(chain => chain.WorldStateManager.FlushCache(CancellationToken.None)); string[] wasmTargets = chain.StylusTargetConfig.GetWasmTargets().ToArray(); ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation(new RecordBlockCreationParameters(digestMessage.Index, digestMessage.Message, WasmTargets: wasmTargets)); @@ -158,7 +161,7 @@ public async Task RecordBlockCreation_ExtCodeSizeFollowedByIsZero_StillRecordsTa using ArbitrumRpcTestBlockchain chain = new ArbitrumTestBlockchainBuilder() .WithGenesisBlock(initialBaseFee: (ulong)l1BaseFee) - .Build(); + .Build(); // no need to flush trie here, do it before RecordBlockCreation call Address sender = FullChainSimulationAccounts.Owner.Address; @@ -272,6 +275,9 @@ public async Task RecordBlockCreation_ExtCodeSizeFollowedByIsZero_StillRecordsTa callResult.Result.Should().Be(Result.Success); chain.LatestReceipts()[1].StatusCode.Should().Be(StatusCode.Success); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Step 5: Call RecordBlockCreation to generate the witness ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(callParams.Index, callParams.Message, WasmTargets: [])); @@ -374,6 +380,9 @@ public async Task RecordBlockCreation_PrecompileCalls_RecordsArbitrumPrecompileC receipts[1].StatusCode.Should().Be(StatusCode.Success, "ArbSys call should succeed"); receipts[2].StatusCode.Should().Be(StatusCode.Success, "ecrecover call should succeed"); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Call RecordBlockCreation to generate the witness ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(callParams.Index, callParams.Message, WasmTargets: [])); @@ -493,6 +502,9 @@ public async Task RecordBlockCreation_BlockHashOpcode_RecordsStorageTrieNodeInWi call2Result.Result.Should().Be(Result.Success); chain.LatestReceipts()[1].StatusCode.Should().Be(StatusCode.Success); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Step 2: Call the contract again (would use cached value from the block that just got built // if cache were persisted into witness-generating env) // Making sure witness-generating VM has its own empty cache and must access storage, recording the trie nodes. @@ -552,6 +564,9 @@ public async Task RecordBlockCreation_ArbBlockHash_RecordsHeadersInWitness() callResult.Result.Should().Be(Result.Success); chain.LatestReceipts()[1].StatusCode.Should().Be(StatusCode.Success, "ArbBlockHash call should succeed"); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(callParams.Index, callParams.Message, WasmTargets: [])); RecordResult recordResult = ThrowOnFailure(recordResultWrapper, callParams.Index); @@ -668,6 +683,9 @@ public async Task RecordBlockCreation_SubmitRetryableWithEmptyCalldata_RecordsCa await chain.DigestAndGetParams(retryable); result.Result.Should().Be(Result.Success); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); RecordResult recordResult = ThrowOnFailure(recordResultWrapper, digestParams.Index); @@ -776,6 +794,9 @@ public async Task RecordBlockCreation_TryReapRetryableNotExpired_RecordsTimeoutW await chain.DigestAndGetParams(new TestL2Transactions(l1BaseFee, sender, transferTx)); result.Result.Should().Be(Result.Success); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); RecordResult recordResult = ThrowOnFailure(recordResultWrapper, digestParams.Index); @@ -812,6 +833,9 @@ public async Task RecordBlockCreation_NonUserTransaction_RecordsBrotliCompressio await chain.DigestAndGetParams(new TestEndOfBlock(l1BaseFee)); result.Result.Should().Be(Result.Success); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); RecordResult recordResult = ThrowOnFailure(recordResultWrapper, digestParams.Index); @@ -931,6 +955,9 @@ public async Task RecordBlockCreation_WhenStorageSlotModifiedAndResetInSameBlock chain.LatestReceipts()[1].StatusCode.Should().Be(StatusCode.Success, "TX1 (set to 2) should succeed"); chain.LatestReceipts()[2].StatusCode.Should().Be(StatusCode.Success, "TX2 (reset to 1) should succeed"); + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Record block creation and generate witness ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); @@ -993,7 +1020,7 @@ public async Task RecordBlockCreation_WhenStateModifiedAndResetDirectlyViaWorldS depositResult.Result.Should().Be(Result.Success); // Pre-populate NetworkFeeAccount (ArbOS root storage, offset 3) with a known original value. - // This creates a leaf trie node at that storage path so the assertion targets something unique. + // This creates a leaf trie node at that storage path so the assertion targets a unique trie node. Address originalFeeAccount = TestItem.AddressB; chain.AppendBlock(chain => { @@ -1062,6 +1089,9 @@ public async Task RecordBlockCreation_WhenStateModifiedAndResetDirectlyViaWorldS "NetworkFeeAccount should retain its original value after modify + reset in the same block"); } + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Record block creation and generate witness ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); @@ -1191,6 +1221,9 @@ public async Task RecordBlockCreation_TransactionSetsSomeStateButReverts_StillRe "address should not be registered since the transaction reverted"); } + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore during witness generation + chain.WorldStateManager.FlushCache(CancellationToken.None); + // Record the block and generate the witness ResultWrapper recordResultWrapper = await chain.ArbitrumRpcModule.RecordBlockCreation( new RecordBlockCreationParameters(digestParams.Index, digestParams.Message, WasmTargets: [])); diff --git a/src/Nethermind.Arbitrum.Test/Execution/Stateless/StateReconstructorTests.cs b/src/Nethermind.Arbitrum.Test/Execution/Stateless/StateReconstructorTests.cs new file mode 100644 index 000000000..d2db40959 --- /dev/null +++ b/src/Nethermind.Arbitrum.Test/Execution/Stateless/StateReconstructorTests.cs @@ -0,0 +1,570 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md + +using Autofac; +using FluentAssertions; +using Nethermind.Arbitrum.Data; +using Nethermind.Arbitrum.Execution.Stateless; +using Nethermind.Arbitrum.Test.Infrastructure; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Db; +using Nethermind.JsonRpc; +using Nethermind.Trie; +using Nethermind.Trie.Pruning; + +namespace Nethermind.Arbitrum.Test.Execution.Stateless; + +public class StateReconstructorTests +{ + private const string RecordingPath = "./Recordings/1__arbos32_basefee92.jsonl"; + + [Test] + public async Task RecordBlockCreation_WithFullyPrunedState_ReconstructsStateFromGenesis() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + DigestMessageParameters lastDigestMessage = GetLastDigestedMessage(); + long headNumber = (long)lastDigestMessage.Index; + + // Switch to pruned mode — only genesis root is "available" + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot }); + + // Verify ALL non-genesis state roots are NOT available before reconstruction + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"genesis state root should be available before reconstruction"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before reconstruction"); + } + + // RecordBlockCreation triggers state reconstruction from the nearest available state (genesis, in this case) + ResultWrapper recordResult = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(lastDigestMessage.Index, lastDigestMessage.Message, WasmTargets: [])); + + recordResult.Result.Should().Be(Result.Success); + recordResult.Data.BlockHash.Should().Be(new Hash256(RecordingTests.Block18Hash)); + recordResult.Data.Preimages.Should().NotBeEmpty(); + + // All state roots got reconstructed from genesis to head - 1 (RecordBlockCreation is read only!), + // but due to reconstructed state pruning, all these intermediate state root reconstructions got pruned! + // So, we get the same state as before the call to RecordBlockCreation. + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available after reconstruction"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available after reconstruction"); + } + } + + [Test] + public async Task RecordBlockCreation_WithPartiallyPrunedState_ReconstructsStateFromNearestAvailable() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + DigestMessageParameters lastDigestMessage = GetLastDigestedMessage(); + long headNumber = (long)lastDigestMessage.Index; + + // Switch to pruned mode — genesis and an intermediate block are available + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + long intermediateBlockNumber = (long)chain.GenesisBlockNumber + 7; + Hash256 intermediateStateRoot = chain.BlockTree.FindHeader(intermediateBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot, intermediateStateRoot }); + + // Verify state roots except for genesis and the intermediate block are NOT available before reconstruction + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber || blockNum == intermediateBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"genesis and intermediate state roots only should be available before reconstruction"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before reconstruction"); + } + + // RecordBlockCreation should reconstruct from the intermediate block, not genesis + ResultWrapper recordResult = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(lastDigestMessage.Index, lastDigestMessage.Message, WasmTargets: [])); + + recordResult.Result.Should().Be(Result.Success); + recordResult.Data.BlockHash.Should().Be(new Hash256(RecordingTests.Block18Hash)); + recordResult.Data.Preimages.Should().NotBeEmpty(); + + // All state roots got reconstructed from the intermediate block to head - 1 (RecordBlockCreation is read only!), + // but due to reconstructed state pruning, all these intermediate state root reconstructions got pruned! + // So, we get the same state as before the call to RecordBlockCreation. + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber || blockNum == intermediateBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"genesis and intermediate state roots only should be available before reconstruction"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before reconstruction"); + } + } + + [Test] + public async Task RecordBlockCreation_StateAlreadyAvailable_SkipsReconstruction() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + DigestMessageParameters lastDigestMessage = GetLastDigestedMessage(); + + // Make target's parent's state root available from the start + long targetParentBlockNumber = (long)lastDigestMessage.Index - 1; + Hash256 targetParentStateRoot = chain.BlockTree.FindHeader(targetParentBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { targetParentStateRoot }); + + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)chain.LatestL2BlockIndex; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == targetParentBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"parent state root for block {blockNum} should be available before RecordBlockCreation"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before RecordBlockCreation"); + } + + ResultWrapper recordResult = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(lastDigestMessage.Index, lastDigestMessage.Message, WasmTargets: [])); + + recordResult.Result.Should().Be(Result.Success); + recordResult.Data.BlockHash.Should().Be(new Hash256(RecordingTests.Block18Hash)); + recordResult.Data.Preimages.Should().NotBeEmpty(); + + // As target's parent's state root is already available, EnsureStateAvailable is a no-op + // and RecordBlockCreation is read only, so state roots availability should be unchanged after the call. + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)chain.LatestL2BlockIndex; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == targetParentBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"parent state root for block {blockNum} should be available after RecordBlockCreation"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available after RecordBlockCreation"); + } + } + + [Test] + public void PrepareForRecord_WithFullyPrunedState_ReconstructsAllStatesInRange() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + long headNumber = chain.BlockTree.Head!.Number; + + // Switch to pruned mode — only genesis root is "available" + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot }); + + // Verify state roots are NOT available before PrepareForRecord + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"genesis state root should be available before PrepareForRecord"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before PrepareForRecord"); + } + + ulong start = 5; + ulong end = 10; + ResultWrapper result = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(start, end)); + result.Result.Should().Be(Result.Success); + + // State roots for all blocks in the range [start-1, end] (in addition to genesis) should now be available. + // StateReconstructor also reconstructed the blocks before the start block (from nearest available, + // here genesis) in order to reconstruct the blocks in the range, but those ones then got pruned + // as not pinned/referenced (for future use). + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber || blockNum >= (long)start - 1 && blockNum <= (long)end) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available after PrepareForRecord"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available after PrepareForRecord"); + } + } + + [Test] + public void PrepareForRecord_WithPartiallyPrunedState_ReconstructsFromNearestAvailable() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + long headNumber = chain.BlockTree.Head!.Number; + + // Switch to pruned mode — genesis and an intermediate block are available + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + long intermediateBlockNumber = (long)chain.GenesisBlockNumber + 9; + Hash256 intermediateStateRoot = chain.BlockTree.FindHeader(intermediateBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot, intermediateStateRoot }); + + // Verify state roots after the intermediate block are NOT available + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == intermediateBlockNumber || blockNum == (long)chain.GenesisBlockNumber) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available before PrepareForRecord"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available before PrepareForRecord"); + } + + ulong start = 13; + ulong end = 17; + ResultWrapper result = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(start, end)); + result.Result.Should().Be(Result.Success); + + // State roots for all blocks in the range [start-1, end] (in addition to the already available + // genesis and the intermediate block) should now be available. + // StateReconstructor also reconstructed the blocks before the start block (from nearest available, + // here the intermediate block) in order to reconstruct the blocks in the range, + // but those ones then got pruned as not pinned/referenced (for future use). + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= headNumber; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + if (blockNum == (long)chain.GenesisBlockNumber || blockNum == intermediateBlockNumber || blockNum >= (long)start - 1 && blockNum <= (long)end) + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available after PrepareForRecord"); + else + trieStore.HasRoot(header.StateRoot!).Should().BeFalse( + $"state root for block {blockNum} should not be available after PrepareForRecord"); + } + } + + [Test] + public void PrepareForRecord_StateAlreadyAvailable_SkipsReconstruction() + { + // No state root controller passed to the chain, all state roots are available from the start + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(); + + // All state roots should already be available before PrepareForRecord + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)chain.LatestL2BlockIndex; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available before PrepareForRecord"); + } + + // In archive mode, state is always available — PrepareForRecord is a no-op + ResultWrapper result = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(Start: 10, End: 15)); + + result.Result.Should().Be(Result.Success); + + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)chain.LatestL2BlockIndex; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + trieStore.HasRoot(header.StateRoot!).Should().BeTrue( + $"state root for block {blockNum} should be available after PrepareForRecord"); + } + } + + [Test] + public void PrepareForRecord_InvalidRange_ReturnsError() + { + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(); + + ulong start = 10; + ulong end = 5; + ResultWrapper result = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(start, end)); + + result.Result.Should().NotBe(Result.Success); + result.Result.Error.Should().Be($"Invalid range: start {start} > end {end}"); + } + + [Test] + public async Task PrepareForRecord_ThenRecordBlockCreation_PreparedStateRemainsAvailable() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore); + + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot }); + + ulong prepareStart = 14; + ulong prepareEnd = 17; + ResultWrapper prepareResult = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(prepareStart, prepareEnd)); + prepareResult.Result.Should().Be(Result.Success); + + // PrepareForRecord also includes the parent state (prepareStart-1) so RecordBlockCreation can access it + long overlayStart = (long)prepareStart - 1; + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= chain.BlockTree.Head!.Number; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + + bool shouldBeAvailable = blockNum == (long)chain.GenesisBlockNumber + || (blockNum >= overlayStart && blockNum <= (long)prepareEnd); + + trieStore.HasRoot(header.StateRoot!).Should().Be(shouldBeAvailable, + $"block {blockNum} state should {(shouldBeAvailable ? "" : "not ")}be available after PrepareForRecord"); + } + + // RecordBlockCreation for block 18 uses block 17's already-prepared state — no reconstruction needed + DigestMessageParameters lastMessage = GetLastDigestedMessage(); + ResultWrapper recordResult = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(lastMessage.Index, lastMessage.Message, WasmTargets: [])); + + recordResult.Result.Should().Be(Result.Success); + recordResult.Data.Preimages.Should().NotBeEmpty(); + + // PrepareForRecord-pinned states are unaffected by the RecordBlockCreation + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= chain.BlockTree.Head!.Number; blockNum++) + { + BlockHeader header = chain.BlockTree.FindHeader(blockNum)!; + + bool shouldBeAvailable = blockNum == (long)chain.GenesisBlockNumber + || (blockNum >= overlayStart && blockNum <= (long)prepareEnd); + + trieStore.HasRoot(header.StateRoot!).Should().Be(shouldBeAvailable, + $"block {blockNum} state should {(shouldBeAvailable ? "" : "not ")}be available after RecordBlockCreation"); + } + } + + [Test] + public void PrepareForRecord_WithSmallMaxStatesPrepared_EvictsOldStates() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore, maxStatesPrepared: 3); + + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot }); + + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + + // First PrepareForRecord: 4 states [4,5,6,7] prepared but max=3 → block 4 immediately evicted + ResultWrapper firstResult = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(Start: 5, End: 7)); + firstResult.Result.Should().Be(Result.Success); + + trieStore.HasRoot(chain.BlockTree.FindHeader(4)!.StateRoot!).Should().BeFalse( + "block 4 was the oldest in the queue and should have been evicted when max was exceeded"); + trieStore.HasRoot(chain.BlockTree.FindHeader(5)!.StateRoot!).Should().BeTrue("block 5 should be available"); + trieStore.HasRoot(chain.BlockTree.FindHeader(6)!.StateRoot!).Should().BeTrue("block 6 should be available"); + trieStore.HasRoot(chain.BlockTree.FindHeader(7)!.StateRoot!).Should().BeTrue("block 7 should be available"); + + // Second PrepareForRecord: 4 more states [9,10,11,12] added → queue [5,6,7,9,10,11,12], keep 3 most recent [10,11,12] + ResultWrapper secondResult = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(Start: 10, End: 12)); + secondResult.Result.Should().Be(Result.Success); + + trieStore.HasRoot(chain.BlockTree.FindHeader(5)!.StateRoot!).Should().BeFalse("block 5 should be evicted"); + trieStore.HasRoot(chain.BlockTree.FindHeader(6)!.StateRoot!).Should().BeFalse("block 6 should be evicted"); + trieStore.HasRoot(chain.BlockTree.FindHeader(7)!.StateRoot!).Should().BeFalse("block 7 should be evicted"); + // Block 9 was reconstructed as an intermediate but wasn't kept: it was enqueued then immediately evicted + trieStore.HasRoot(chain.BlockTree.FindHeader(9)!.StateRoot!).Should().BeFalse( + "block 9 was evicted as the oldest remaining state after second PrepareForRecord"); + trieStore.HasRoot(chain.BlockTree.FindHeader(10)!.StateRoot!).Should().BeTrue("block 10 should be available"); + trieStore.HasRoot(chain.BlockTree.FindHeader(11)!.StateRoot!).Should().BeTrue("block 11 should be available"); + trieStore.HasRoot(chain.BlockTree.FindHeader(12)!.StateRoot!).Should().BeTrue("block 12 should be available"); + } + + [Test] + public async Task PrepareForRecord_InterleavedWithRecordBlockCreation_MaintainsCorrectAvailability() + { + SwitchableReadOnlyTrieStore switchableStore = new(); + // max=5 so the first PrepareForRecord [3,4,5,6,7] fits exactly without eviction + using ArbitrumRpcTestBlockchain chain = BuildChainWithRecording(switchableStore, maxStatesPrepared: 5); + + Hash256 genesisStateRoot = chain.BlockTree.FindHeader((long)chain.GenesisBlockNumber)!.StateRoot!; + switchableStore.EnablePruning(new HashSet { genesisStateRoot }); + + ReconstructedStateTrieStore trieStore = chain.Container.Resolve(); + + // Phase 1: prepare states for blocks 3-7 (PrepareForRecord includes start-1=3) + ulong start1 = 4; + ulong end1 = 7; + ResultWrapper firstPrepare = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(start1, end1)); + firstPrepare.Result.Should().Be(Result.Success); + + DigestMessageParameters lastDigestMsg = GetLastDigestedMessage(); + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)lastDigestMsg.Index; blockNum++) + { + if (blockNum == (long)chain.GenesisBlockNumber || (blockNum >= (long)start1 - 1 && blockNum <= (long)end1)) + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeTrue( + $"block {blockNum} state should be available after first PrepareForRecord"); + else + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeFalse( + $"block {blockNum} state should not be available after first PrepareForRecord"); + } + + // Phase 2: (Unordered) RecordBlockCreation calls reuse prepared states — no reconstruction, prepared states unaffected + DigestMessageParameters msg8 = GetDigestedMessage(8); + ResultWrapper record8 = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(msg8.Index, msg8.Message, WasmTargets: [])); + record8.Result.Should().Be(Result.Success); + record8.Data.Preimages.Should().NotBeEmpty(); + + DigestMessageParameters msg6 = GetDigestedMessage(6); + ResultWrapper record6 = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(msg6.Index, msg6.Message, WasmTargets: [])); + record6.Result.Should().Be(Result.Success); + record6.Data.Preimages.Should().NotBeEmpty(); + + // Prepared states are unchanged after read-only RecordBlockCreations + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)lastDigestMsg.Index; blockNum++) + { + if (blockNum == (long)chain.GenesisBlockNumber || (blockNum >= (long)start1 - 1 && blockNum <= (long)end1)) + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeTrue( + $"block {blockNum} state should be available after first RecordBlockCreation"); + else + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeFalse( + $"block {blockNum} state should not be available after first RecordBlockCreation"); + } + + // Phase 3: second PrepareForRecord prepares [11,12,13,14] → queue [3,4,5,6,7,11,12,13,14], evict 4 oldest [3,4,5,6] + ResultWrapper secondPrepare = chain.ArbitrumRpcModule.PrepareForRecord( + new PrepareForRecordParameters(Start: 12, End: 14)); + secondPrepare.Result.Should().Be(Result.Success); + + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)lastDigestMsg.Index; blockNum++) + { + if (blockNum == (long)chain.GenesisBlockNumber || blockNum == 7 || blockNum == 11 || blockNum == 12 || blockNum == 13 || blockNum == 14) + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeTrue( + $"block {blockNum} state should be available after second PrepareForRecord"); + else + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeFalse( + $"block {blockNum} state should not be available after second PrepareForRecord"); + } + + // Phase 4: one last RecordBlockCreation call that does not find parent state: reconstructs it temporarily + // and evicts it before the call returns. The prepared states remain unaffected. + DigestMessageParameters msg7 = GetDigestedMessage(7); + ResultWrapper record7 = await chain.ArbitrumRpcModule.RecordBlockCreation( + new RecordBlockCreationParameters(msg7.Index, msg7.Message, WasmTargets: [])); + record7.Result.Should().Be(Result.Success); + record7.Data.Preimages.Should().NotBeEmpty(); + + for (long blockNum = (long)chain.GenesisBlockNumber; blockNum <= (long)lastDigestMsg.Index; blockNum++) + { + if (blockNum == (long)chain.GenesisBlockNumber || blockNum == 7 || blockNum == 11 || blockNum == 12 || blockNum == 13 || blockNum == 14) + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeTrue( + $"block {blockNum} state should be available after second RecordBlockCreation"); + else + trieStore.HasRoot(chain.BlockTree.FindHeader(blockNum)!.StateRoot!).Should().BeFalse( + $"block {blockNum} state should not be available after second RecordBlockCreation"); + } + } + + private static ArbitrumRpcTestBlockchain BuildChainWithRecording( + SwitchableReadOnlyTrieStore? switchableStore = null, + int? maxStatesPrepared = null) + { + FullChainSimulationRecordingFile recording = new(RecordingPath); + + ArbitrumTestBlockchainBuilder builder = new ArbitrumTestBlockchainBuilder() + .WithRecording(recording); + + if (switchableStore is not null) + builder.WithContainerConfigurer(b => b.AddSingleton(ctx => + new ReconstructedStateTrieStore(new MemDb(), switchableStore.Wrap(ctx.Resolve())))); + + if (maxStatesPrepared.HasValue) + builder.WithArbitrumConfig(cfg => cfg.ValidatorMaxStatesPrepared = maxStatesPrepared.Value); + + // Flush trie nodes to underlying nodeStorage to make state roots accessible for ReconstructedStateTrieStore + return builder.Build(chain => chain.WorldStateManager.FlushCache(CancellationToken.None)); + } + + private static DigestMessageParameters GetLastDigestedMessage() + { + FullChainSimulationRecordingFile recording = new(RecordingPath); + return recording.GetDigestMessages().Last(); + } + + private static DigestMessageParameters GetDigestedMessage(ulong index) + { + FullChainSimulationRecordingFile recording = new(RecordingPath); + return recording.GetDigestMessages().Single(m => m.Index == index); + } + + /// + /// A controller that wraps an IReadOnlyTrieStore with switchable HasRoot behavior. + /// Initially passes through all calls (including HasRoot) to the real store. + /// After EnablePruning() is called, HasRoot returns false for roots not in the allowed set, + /// while all other operations still delegate to the real store. + /// This simulates pruning mode where the few available trie nodes are "on disk" while all the others have been evicted from the dirty cache. + /// + private class SwitchableReadOnlyTrieStore + { + private HashSet? _availableRoots; + + public IReadOnlyTrieStore Wrap(IReadOnlyTrieStore inner) => new Wrapper(inner, this); + + public void EnablePruning(HashSet availableRoots) => _availableRoots = availableRoots; + + private class Wrapper(IReadOnlyTrieStore inner, SwitchableReadOnlyTrieStore controller) : IReadOnlyTrieStore + { + public void Dispose() { } + + public TrieNode FindCachedOrUnknown(Hash256? address, in TreePath path, Hash256 hash) + => inner.FindCachedOrUnknown(address, in path, hash); + + public byte[]? LoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + => inner.LoadRlp(address, in path, hash, flags); + + // ReconstructedStateTrieStore.HasRoot() will call this method to check if a state root is available, + // so we override it to implement the pruning behavior. + // But we also need to make sure it does not impact regular calls to it when fetching nodes outside of state roots. + public byte[]? TryLoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + { + // If the controller overrides available roots and we are loading a state root (address is null and path is empty), + // only return the node if it's in the available set + if (controller._availableRoots is not null && address is null && path.Length == 0) + return controller._availableRoots.Contains(hash) ? inner.TryLoadRlp(address, in path, hash, flags) : null; + + return inner.TryLoadRlp(address, in path, hash, flags); + } + + public INodeStorage.KeyScheme Scheme => inner.Scheme; + + public ICommitter BeginCommit(Hash256? address, TrieNode? root, WriteFlags writeFlags) + => inner.BeginCommit(address, root, writeFlags); + + // This method should not even be called as ReconstructedStateTrieStore.HasRoot() will call this.TryLoadRlp() directly! + public bool HasRoot(Hash256 stateRoot) + => throw new UnauthorizedAccessException("Method HasRoot should not be called"); + + public IDisposable BeginScope(BlockHeader? baseBlock) => inner.BeginScope(baseBlock); + + public IScopedTrieStore GetTrieStore(Hash256? address) => inner.GetTrieStore(address); + + public IBlockCommitter BeginBlockCommit(long blockNumber) => inner.BeginBlockCommit(blockNumber); + } + } +} diff --git a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumRpcTestBlockchain.cs b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumRpcTestBlockchain.cs index 3aed68a47..902142380 100644 --- a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumRpcTestBlockchain.cs +++ b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumRpcTestBlockchain.cs @@ -386,6 +386,7 @@ private static ArbitrumRpcTestBlockchain CreateInternal(ArbitrumRpcTestBlockchai chain.BlockProcessingQueue, chain.Container.Resolve(), chain.Container.Resolve(), + chain.Dependencies.StateReconstructor, chain.Container.Resolve()); chain.ArbitrumRpcModule = new ArbitrumRpcModuleWrapper(chain, new ArbitrumRpcModule(engine)); @@ -554,6 +555,11 @@ public Task> RecordBlockCreation(RecordBlockCreation { return rpc.RecordBlockCreation(parameters); } + + public ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters) + { + return rpc.PrepareForRecord(parameters); + } } public class ScopedGlobalWorldStateAccessor(ArbitrumRpcTestBlockchain chain) diff --git a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBase.cs b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBase.cs index bceebc2da..389267253 100644 --- a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBase.cs +++ b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBase.cs @@ -42,6 +42,7 @@ using Nethermind.TxPool; using NSubstitute; using BlockchainProcessorOptions = Nethermind.Consensus.Processing.BlockchainProcessor.Options; +using Nethermind.Arbitrum.Execution.Stateless; namespace Nethermind.Arbitrum.Test.Infrastructure; @@ -323,6 +324,7 @@ protected record BlockchainContainerDependencies( CachedL1PriceData CachedL1PriceData, IWasmStore WasmStore, IArbosVersionProvider ArbosVersionProvider, + IStateReconstructor StateReconstructor, IArbitrumSpecHelper SpecHelper); private void InitializeArbitrumPluginSteps(IContainer container) diff --git a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBuilder.cs b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBuilder.cs index 3d2474077..8209fc14a 100644 --- a/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBuilder.cs +++ b/src/Nethermind.Arbitrum.Test/Infrastructure/ArbitrumTestBlockchainBuilder.cs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 // SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md +using Autofac; using Nethermind.Arbitrum.Config; using Nethermind.Arbitrum.Data; using Nethermind.Core; @@ -15,6 +16,7 @@ public class ArbitrumTestBlockchainBuilder private readonly List> _configurations = new(); private ChainSpec _chainSpec = FullChainSimulationChainSpecProvider.Create(); private Action? _configureArbitrum; + private Action? _configurer; public ArbitrumTestBlockchainBuilder WithChainSpec(ChainSpec chainSpec) { @@ -28,6 +30,12 @@ public ArbitrumTestBlockchainBuilder WithArbitrumConfig(Action c return this; } + public ArbitrumTestBlockchainBuilder WithContainerConfigurer(Action configurer) + { + _configurer = configurer; + return this; + } + public ArbitrumTestBlockchainBuilder WithGenesisBlock(ulong initialBaseFee = 92, ulong arbosVersion = 32) { _chainSpec = FullChainSimulationChainSpecProvider.Create(arbosVersion); @@ -74,13 +82,15 @@ public ArbitrumTestBlockchainBuilder WithRecording(IFullChainSimulationRecording return this; } - public ArbitrumRpcTestBlockchain Build() + public ArbitrumRpcTestBlockchain Build(Action? afterBuild = null) { - ArbitrumRpcTestBlockchain chain = ArbitrumRpcTestBlockchain.CreateDefault(chainSpec: _chainSpec, configureArbitrum: _configureArbitrum); + ArbitrumRpcTestBlockchain chain = ArbitrumRpcTestBlockchain.CreateDefault(configurer: _configurer, chainSpec: _chainSpec, configureArbitrum: _configureArbitrum); foreach (Action configuration in _configurations) configuration(chain); + afterBuild?.Invoke(chain); + return chain; } diff --git a/src/Nethermind.Arbitrum.Test/RecordingTests.cs b/src/Nethermind.Arbitrum.Test/RecordingTests.cs index 449aa2ce2..17e19d42e 100644 --- a/src/Nethermind.Arbitrum.Test/RecordingTests.cs +++ b/src/Nethermind.Arbitrum.Test/RecordingTests.cs @@ -10,7 +10,9 @@ namespace Nethermind.Arbitrum.Test; public class RecordingTests { - [TestCase("./Recordings/1__arbos32_basefee92.jsonl", 18, "0x131320467d82b8bfd1fc6504ed4e13802b7e427b1c3d1ff3c367737d4fc18fa9")] + public const string Block18Hash = "0x131320467d82b8bfd1fc6504ed4e13802b7e427b1c3d1ff3c367737d4fc18fa9"; + + [TestCase("./Recordings/1__arbos32_basefee92.jsonl", 18, Block18Hash)] [TestCase("./Recordings/2__stylus.jsonl", 18, "0x13acf142e2463eaf5049f9fe1b64f0bf5d8c6ea7ebfd950335582e7a63746ced")] [TestCase("./Recordings/2__stylus.jsonl", 19, "0xe869b42547c1c017efb9043524612975f2404412f10878a8d1f273ba11c3df83")] // Solidity Counter [TestCase("./Recordings/2__stylus.jsonl", 20, "0x2e8e1d1e4e868a5657e36383d586df9eaee84814eea51fe1b1709d976c65e820")] // Solidity Call diff --git a/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.DigestMessage.cs b/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.DigestMessage.cs index 286b51f63..cfae24e6f 100644 --- a/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.DigestMessage.cs +++ b/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.DigestMessage.cs @@ -189,7 +189,7 @@ public async Task DigestMessage_L2MessageCallContract_CallsContract() } [Test] - [TestCase(18UL, "0x131320467d82b8bfd1fc6504ed4e13802b7e427b1c3d1ff3c367737d4fc18fa9")] + [TestCase(18UL, RecordingTests.Block18Hash)] [TestCase(12UL, "0x370d29c3638d32f7d8d142feb177362ad56c9ebb34ac7fb6a629fa1aa4ea6a89")] [TestCase(0UL, "0xbd9f2163899efb7c39f945c9a7744b2c3ff12cfa00fe573dcb480a436c0803a8")] public async Task DigestMessage_BlockAlreadyExists_ReturnsExistingBlockHash(ulong blockNumber, string blockHashStr) diff --git a/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.cs b/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.cs index c9b2a1906..3f4c04cd1 100644 --- a/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.cs +++ b/src/Nethermind.Arbitrum.Test/Rpc/ArbitrumRpcModuleTests.cs @@ -43,6 +43,7 @@ public abstract class ArbitrumRpcModuleTests private Mock _mainProcessingContextMock = null!; private ISpecProvider _specProvider = null!; private Mock _witnessGeneratingBlockProcessingEnvFactory = null!; + private Mock _stateReconstructor = null!; [SetUp] public void Setup() { @@ -57,6 +58,7 @@ public void Setup() _blockProcessingQueue = new Mock(); _specProvider = FullChainSimulationChainSpecProvider.CreateDynamicSpecProvider(_chainSpec); _witnessGeneratingBlockProcessingEnvFactory = new Mock(); + _stateReconstructor = new Mock(); ArbitrumChainSpecEngineParameters parameters = _chainSpec.EngineChainSpecParametersProvider .GetChainSpecParameters(); @@ -90,6 +92,7 @@ public void Setup() _blockProcessingQueue.Object, _arbitrumConfig, _witnessGeneratingBlockProcessingEnvFactory.Object, + _stateReconstructor.Object, _blockConfig); _rpcModule = new ArbitrumRpcModule(engine); @@ -254,6 +257,7 @@ public async Task HeadMessageIndex_Always_ReturnsHeadMessageIndex() _blockProcessingQueue.Object, _arbitrumConfig, _witnessGeneratingBlockProcessingEnvFactory.Object, + _stateReconstructor.Object, _blockConfig); _rpcModule = new ArbitrumRpcModule(engine); @@ -287,6 +291,7 @@ public async Task HeadMessageIndex_HasNoBlocks_NoLatestHeaderFound() _blockProcessingQueue.Object, _arbitrumConfig, _witnessGeneratingBlockProcessingEnvFactory.Object, + _stateReconstructor.Object, _blockConfig); _rpcModule = new ArbitrumRpcModule(engine); @@ -326,6 +331,7 @@ public async Task HeadMessageIndex_BlockNumberIsLowerThanGenesis_Fails() _blockProcessingQueue.Object, _arbitrumConfig, _witnessGeneratingBlockProcessingEnvFactory.Object, + _stateReconstructor.Object, _blockConfig); _rpcModule = new ArbitrumRpcModule(engine); diff --git a/src/Nethermind.Arbitrum/ArbitrumPlugin.cs b/src/Nethermind.Arbitrum/ArbitrumPlugin.cs index 1b42f77b9..464391328 100644 --- a/src/Nethermind.Arbitrum/ArbitrumPlugin.cs +++ b/src/Nethermind.Arbitrum/ArbitrumPlugin.cs @@ -47,6 +47,7 @@ using Nethermind.Arbitrum.Tracing; using Nethermind.Blockchain.Tracing.GethStyle.Custom.Native; using Nethermind.State; +using Nethermind.Trie.Pruning; namespace Nethermind.Arbitrum; @@ -158,6 +159,33 @@ public Task InitRpcModules() _api.RpcModuleProvider.RegisterSingle(debugModule); } + // Flush the trie dirty cache to disk on shutdown so that the latest processed blocks' + // state is persisted. Without this, Nethermind only persists up to head - PruningBoundary, + // while Nitro persists up to head. On restart, the mismatch causes Nitro to detect an error. + // _api.ProcessExit!.Token.Register(() => + // { + // ILogger logger = _api.LogManager.GetClassLogger(); + // if (logger.IsInfo) + // logger.Info("Flushing trie cache to disk before shutdown..."); + + // _api.WorldStateManager?.FlushCache(CancellationToken.None); + + // // FlushCache persists state but does not update BestPersistedState because + // // TrieStore.AnnounceReorgBoundaries() requires LatestCommittedBlockNumber >= LastPersistedBlockNumber + maxDepth, + // // which is never met when persisting up to the head block itself. + // // Set it directly so that on restart the node head matches the last processed block. + // long? headNumber = _api.BlockTree!.Head?.Number; + // if (headNumber.HasValue) + // { + // if (logger.IsInfo) + // logger.Info($"Setting BestPersistedState to {headNumber.Value}."); + // _api.BlockTree!.BestPersistedState = headNumber.Value; + // } + + // if (logger.IsInfo) + // logger.Info("Trie cache flushed."); + // }); + return Task.CompletedTask; } @@ -294,6 +322,9 @@ protected override void Load(ContainerBuilder builder) .AddSingleton() .Bind, ArbitrumEthModuleFactory>() + // Execution recording (state reconstruction + witness generation) + .AddSingleton(ctx => new ReconstructedStateTrieStore(new Db.MemDb(), ctx.Resolve())) + .AddSingleton() .AddSingleton() .Bind() diff --git a/src/Nethermind.Arbitrum/Arbos/Programs/StylusParams.cs b/src/Nethermind.Arbitrum/Arbos/Programs/StylusParams.cs index f8d8e7572..357468484 100644 --- a/src/Nethermind.Arbitrum/Arbos/Programs/StylusParams.cs +++ b/src/Nethermind.Arbitrum/Arbos/Programs/StylusParams.cs @@ -5,9 +5,9 @@ using System.Diagnostics.CodeAnalysis; using Nethermind.Arbitrum.Arbos.Storage; using Nethermind.Arbitrum.Data.Transactions; +using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; -using Nethermind.Core; using Nethermind.Int256; namespace Nethermind.Arbitrum.Arbos.Programs; diff --git a/src/Nethermind.Arbitrum/Config/ArbitrumConfig.cs b/src/Nethermind.Arbitrum/Config/ArbitrumConfig.cs index 82797d09b..331e7a573 100644 --- a/src/Nethermind.Arbitrum/Config/ArbitrumConfig.cs +++ b/src/Nethermind.Arbitrum/Config/ArbitrumConfig.cs @@ -2,7 +2,6 @@ // SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md using System.Diagnostics; -using Nethermind.Arbitrum.Stylus; namespace Nethermind.Arbitrum.Config; @@ -14,6 +13,7 @@ public class ArbitrumConfig : IArbitrumConfig public WasmRebuildMode RebuildLocalWasm { get; set; } = WasmRebuildMode.Auto; public int MessageLagMs { get; set; } = 1000; public bool ExposeMultiGas { get; set; } = false; + public int ValidatorMaxStatesPrepared { get; set; } = 1000; } public static class ArbitrumConfigExtensions diff --git a/src/Nethermind.Arbitrum/Config/IArbitrumConfig.cs b/src/Nethermind.Arbitrum/Config/IArbitrumConfig.cs index 4bdff1844..3c4ca364d 100644 --- a/src/Nethermind.Arbitrum/Config/IArbitrumConfig.cs +++ b/src/Nethermind.Arbitrum/Config/IArbitrumConfig.cs @@ -25,4 +25,7 @@ public interface IArbitrumConfig : IConfig [ConfigItem(Description = "Experimental: Expose multi-dimensional gas in transaction receipts", DefaultValue = "false")] bool ExposeMultiGas { get; set; } + + [ConfigItem(Description = "Maximum number of state roots to keep pinned in the MemDb overlay simultaneously", DefaultValue = "1000")] + int ValidatorMaxStatesPrepared { get; set; } } diff --git a/src/Nethermind.Arbitrum/Data/DigestMessageParameters.cs b/src/Nethermind.Arbitrum/Data/DigestMessageParameters.cs index 2b694092e..bc6a480f9 100644 --- a/src/Nethermind.Arbitrum/Data/DigestMessageParameters.cs +++ b/src/Nethermind.Arbitrum/Data/DigestMessageParameters.cs @@ -64,3 +64,8 @@ public record RecordBlockCreationParameters( [property: JsonPropertyName("message")] MessageWithMetadata Message, [property: JsonPropertyName("wasmTargets")] string[] WasmTargets ); + +public record PrepareForRecordParameters( + [property: JsonPropertyName("start")] ulong Start, + [property: JsonPropertyName("end")] ulong End +); diff --git a/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngine.cs b/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngine.cs index f59824b38..53f331c1c 100644 --- a/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngine.cs +++ b/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngine.cs @@ -40,6 +40,7 @@ public sealed class ArbitrumExecutionEngine( IBlockProcessingQueue processingQueue, IArbitrumConfig arbitrumConfig, IArbitrumWitnessGeneratingBlockProcessingEnvFactory witnessGeneratingBlockProcessingEnvFactory, + IStateReconstructor stateReconstructor, IBlocksConfig blocksConfig) : IArbitrumExecutionEngine { @@ -534,6 +535,9 @@ public async Task> RecordBlockCreation(RecordBlockCr Number = blockNumber }; + // temporary reference to parent trie + stateReconstructor.EnsureStateAvailable(parent); + string[] wasmTargets = parameters.WasmTargets; string localTarget = StylusTargets.GetLocalTargetName(); if (!wasmTargets.Contains(localTarget)) @@ -543,6 +547,9 @@ public async Task> RecordBlockCreation(RecordBlockCr IBlockBuildingWitnessCollector witnessCollector = ((IWitnessGeneratingPolyvalentEnv)scope.Env).CreateBlockBuildingWitnessCollector(); (Block builtBlock, ArbitrumWitness witness) = await witnessCollector.BuildBlockAndGetWitness(parent, payload); + // references to parent trie are now removed + stateReconstructor.DereferenceRoot(parent.StateRoot!); + using (witness) { if (builtBlock.Hash is null) @@ -585,6 +592,47 @@ void OnBlockAddedToMain(object? sender, BlockReplacementEventArgs e) } } + public ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters) + { + if (parameters.End < parameters.Start) + return ResultWrapper.Fail($"Invalid range: start {parameters.Start} > end {parameters.End}"); + + ulong numOfBlocks = parameters.End + 1 - parameters.Start; + long headerNum = MessageIndexToBlockNumber(parameters.Start).Data; + if (parameters.Start > 0) + headerNum--; // need to get previous as RecordBlockCreation executes from the parent block's state + else + numOfBlocks--; // genesis block doesn't need preparation, so recording one less block + + long lastHeaderNum = headerNum + (long)numOfBlocks; + List referencedStateRoots = new List((int)numOfBlocks); + + for (long current = headerNum; current <= lastHeaderNum; current++) + { + BlockHeader? header = BlockTree.FindHeader(current); + if (header is null) + { + _logger.Warn($"PrepareForRecord: header not found for block {current}"); + break; + } + + try + { + stateReconstructor.EnsureStateAvailable(header); + referencedStateRoots.Add(header.StateRoot!); + } + catch (Exception ex) + { + _logger.Warn($"PrepareForRecord: failed to ensure state for block {current}: {ex.Message}"); + break; + } + } + + stateReconstructor.PreparedAddTrim(referencedStateRoots); + + return ResultWrapper.Success(default); + } + private Hash256 GetSendRootFromBlock(Block block) { ArbitrumBlockHeaderInfo headerInfo = ArbitrumBlockHeaderInfo.Deserialize(block.Header, _logger); diff --git a/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngineWithComparison.cs b/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngineWithComparison.cs index 9b4ad5b9e..3249b7938 100644 --- a/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngineWithComparison.cs +++ b/src/Nethermind.Arbitrum/Execution/ArbitrumExecutionEngineWithComparison.cs @@ -282,4 +282,7 @@ private void TriggerGracefulShutdown(string reason) public Task> RecordBlockCreation(RecordBlockCreationParameters parameters) => innerEngine.RecordBlockCreation(parameters); + + public ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters) + => innerEngine.PrepareForRecord(parameters); } diff --git a/src/Nethermind.Arbitrum/Execution/IArbitrumExecutionEngine.cs b/src/Nethermind.Arbitrum/Execution/IArbitrumExecutionEngine.cs index 37d030d8b..cc4da3038 100644 --- a/src/Nethermind.Arbitrum/Execution/IArbitrumExecutionEngine.cs +++ b/src/Nethermind.Arbitrum/Execution/IArbitrumExecutionEngine.cs @@ -28,4 +28,5 @@ public interface IArbitrumExecutionEngine ResultWrapper> FullSyncProgressMap(); Task> ArbOSVersionForMessageIndexAsync(ulong messageIndex); Task> RecordBlockCreation(RecordBlockCreationParameters parameters); + ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters); } diff --git a/src/Nethermind.Arbitrum/Execution/Stateless/ArbitrumWitnessGeneratingBlockProcessingEnvFactory.cs b/src/Nethermind.Arbitrum/Execution/Stateless/ArbitrumWitnessGeneratingBlockProcessingEnvFactory.cs index a7b8d059e..eb37fc8ff 100644 --- a/src/Nethermind.Arbitrum/Execution/Stateless/ArbitrumWitnessGeneratingBlockProcessingEnvFactory.cs +++ b/src/Nethermind.Arbitrum/Execution/Stateless/ArbitrumWitnessGeneratingBlockProcessingEnvFactory.cs @@ -15,7 +15,6 @@ using Nethermind.Evm.State; using Nethermind.Logging; using Nethermind.State; -using Nethermind.Trie.Pruning; using static Nethermind.Arbitrum.Execution.ArbitrumBlockProcessor; using Nethermind.Arbitrum.Evm; using Nethermind.Arbitrum.Precompiles; @@ -27,6 +26,7 @@ using Nethermind.Config; using Nethermind.Evm; using Nethermind.Arbitrum.Config; +using Nethermind.Trie.Pruning; namespace Nethermind.Arbitrum.Execution.Stateless; @@ -37,7 +37,7 @@ public interface IArbitrumWitnessGeneratingBlockProcessingEnvFactory : IWitnessG public class ArbitrumWitnessGeneratingBlockProcessingEnvFactory( ILifetimeScope rootLifetimeScope, - IReadOnlyTrieStore readOnlyTrieStore, + ReconstructedStateTrieStore reconstructedStateTrieStore, IDbProvider dbProvider, ILogManager logManager) : IArbitrumWitnessGeneratingBlockProcessingEnvFactory { @@ -68,7 +68,7 @@ private static BlocksConfig CreateWitnessBlocksConfig(IBlocksConfig blocksConfig public IWitnessGeneratingBlockProcessingEnvScope CreateScope(string[]? wasmTargets) { IReadOnlyDbProvider readOnlyDbProvider = new ReadOnlyDbProvider(dbProvider, true); - WitnessCapturingTrieStore trieStore = new(readOnlyDbProvider.StateDb, readOnlyTrieStore); + WitnessCapturingTrieStore trieStore = new(reconstructedStateTrieStore); IStateReader stateReader = new StateReader(trieStore, readOnlyDbProvider.CodeDb, logManager); WorldState worldState = new(new TrieStoreScopeProvider(trieStore, readOnlyDbProvider.CodeDb, logManager), logManager); @@ -127,6 +127,7 @@ public IWitnessGeneratingBlockProcessingEnvScope CreateScope(string[]? wasmTarge .AddScoped() // 1st: add the tx executor + .AddScoped() .AddScoped() // 2nd: add block processor @@ -136,7 +137,6 @@ public IWitnessGeneratingBlockProcessingEnvScope CreateScope(string[]? wasmTarge // 3rd: configure the builder for block production (like ArbitrumBlockProducerEnvFactory but with my own witness capturing world state) .AddScoped(factory => factory.Create()) - .AddScoped() .AddDecorator() .AddDecorator() .AddScoped() diff --git a/src/Nethermind.Arbitrum/Execution/Stateless/IStateReconstructor.cs b/src/Nethermind.Arbitrum/Execution/Stateless/IStateReconstructor.cs new file mode 100644 index 000000000..8e4af1d1b --- /dev/null +++ b/src/Nethermind.Arbitrum/Execution/Stateless/IStateReconstructor.cs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md + +using Nethermind.Core; +using Nethermind.Core.Crypto; + +namespace Nethermind.Arbitrum.Execution.Stateless; + +public interface IStateReconstructor +{ + void EnsureStateAvailable(BlockHeader targetParent); + void DereferenceRoot(Hash256 parentStateRoot); + void PreparedAddTrim(List stateRoots); +} diff --git a/src/Nethermind.Arbitrum/Execution/Stateless/ReconstructedStateTrieStore.cs b/src/Nethermind.Arbitrum/Execution/Stateless/ReconstructedStateTrieStore.cs new file mode 100644 index 000000000..37f8bdb4a --- /dev/null +++ b/src/Nethermind.Arbitrum/Execution/Stateless/ReconstructedStateTrieStore.cs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md + +using System.Collections.Concurrent; +using Nethermind.Core; +using Nethermind.Core.Buffers; +using Nethermind.Core.Crypto; +using Nethermind.Core.Extensions; +using Nethermind.Db; +using Nethermind.Serialization.Rlp; +using Nethermind.Trie; +using Nethermind.Trie.Pruning; + +namespace Nethermind.Arbitrum.Execution.Stateless; + +/// +/// Overlay trie store for state reconstruction. Reconstructed trie nodes are stored in a MemDb overlay +/// and fall back to the base store (main TrieStore dirty cache + disk) for reads. +/// BeginScope is a no-op to avoid acquiring the main TrieStore's scope/pruning locks during +/// potentially long-running state reconstruction. +/// +/// +/// Only PrepareForRecord should write to the overlay to reconstruct the needed state, +/// witness generation is read only against the overlay. +/// +public class ReconstructedStateTrieStore(MemDb memDb, IReadOnlyTrieStore baseStore) : ITrieStore, IReadOnlyTrieStore +{ + private readonly INodeStorage _nodeStorage = new NodeStorage(memDb); + private readonly MemDb _memDb = memDb; + + /// Per-MemDb-key reference counts for tracking which nodes are still needed by at least one alive state root. + private readonly ConcurrentDictionary _refCounts = new(Bytes.EqualityComparer); + + private static readonly AccountDecoder _accountDecoder = AccountDecoder.Instance; + + public void Dispose() + { + } + + public TrieNode FindCachedOrUnknown(Hash256? address, in TreePath path, Hash256 hash) + => baseStore.FindCachedOrUnknown(address, in path, hash); + + public byte[]? LoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + { + byte[]? rlp = TryLoadRlp(address, in path, hash, flags); + if (rlp is null) + throw new MissingTrieNodeException("Missing RLP node", address, path, hash); + return rlp; + } + + public byte[]? TryLoadRlp(Hash256? address, in TreePath path, Hash256 hash, ReadFlags flags = ReadFlags.None) + => _nodeStorage.Get(address, in path, hash, flags) ?? baseStore.TryLoadRlp(address, in path, hash, flags); + + /// + /// Checks the local overlay first, then falls back to reading from the base store's persistent + /// node storage (disk). We intentionally avoid because + /// it checks the dirty node cache first, which is volatile: a TOCTOU race can occur where HasRoot + /// returns true (state in dirty cache) but pruning evicts those nodes before reconstruction reads + /// them. bypasses the dirty cache and reads directly + /// from the underlying persistent storage, so it is stable. + /// + public bool HasRoot(Hash256 stateRoot) + => _nodeStorage.Get(null, TreePath.Empty, stateRoot) is not null + || baseStore.TryLoadRlp(null, TreePath.Empty, stateRoot) is not null; + + public IDisposable BeginScope(BlockHeader? baseBlock) => new Reactive.AnonymousDisposable(() => { }); + + public IScopedTrieStore GetTrieStore(Hash256? address) => new ScopedTrieStore(this, address); + + public INodeStorage.KeyScheme Scheme => baseStore.Scheme; + + public IBlockCommitter BeginBlockCommit(long blockNumber) => NullCommitter.Instance; + + public ICommitter BeginCommit(Hash256? address, TrieNode? root, WriteFlags writeFlags) + => new RawScopedTrieStore.Committer(_nodeStorage, address, writeFlags); + + /// + /// Traverses all MemDb-resident trie nodes reachable from the given state root and increments + /// their reference counts. Call when adding a state root to the alive set. + /// + public void Reference(Hash256 stateRoot) + { + Traverse(null, TreePath.Empty, stateRoot, key => + { + _refCounts[key] = _refCounts.TryGetValue(key, out int count) ? count + 1 : 1; + }); + } + + /// + /// Traverses all MemDb-resident trie nodes reachable from the given state root and decrements + /// their reference counts. Nodes whose count reaches zero are evicted from the MemDb. + /// Call when removing a state root from the alive set. + /// + public void Dereference(Hash256 stateRoot) + { + Traverse(null, TreePath.Empty, stateRoot, key => + { + if (!_refCounts.TryGetValue(key, out int count)) + return; + + if (count <= 1) + { + _refCounts.Remove(key, out _); + _memDb.Remove(key); + } + else + { + _refCounts[key] = count - 1; + } + }); + } + + private void Traverse(Hash256? address, TreePath path, Hash256 hash, Action onKey) + { + Stack<(Hash256? address, TreePath path, Hash256 hash)> stack = new(); + stack.Push((address, path, hash)); + + while (stack.TryPop(out (Hash256? addr, TreePath p, Hash256 h) item)) + { + byte[] key = NodeStorage.GetHalfPathNodeStoragePath(item.addr, item.p, item.h); + byte[]? rlp = _memDb[key]; + // If the node is not in memDB, neither are its children. Then no need to reference them. + if (rlp is null) + continue; + + // Push children to stack BEFORE calling onKey (which during Dereference may delete this node). + // Since this is a tree traversal (no intra-trie node sharing under HalfPath scheme), each key + // is visited at most once. + PushChildren(rlp, item.addr, item.p, stack); + onKey(key); + } + } + + private static void PushChildren( + byte[] rlp, + Hash256? address, + TreePath path, + Stack<(Hash256? address, TreePath path, Hash256 hash)> stack) + { + ValueRlpStream stream = new ValueRlpStream(rlp); + stream.ReadSequenceLength(); + int items = stream.PeekNumberOfItemsRemaining(null, 3); + + if (items > 2) + { + // Branch node: up to 16 hash-referenced children + for (int i = 0; i < 16; i++) + { + (int _, int contentLength) = stream.PeekPrefixAndContentLength(); + if (contentLength == 32) + stack.Push((address, path.Append(i), stream.DecodeKeccak()!)); + else + stream.SkipItem(); + } + // Branch value slot (index 16) is not a trie node; skip it. + } + else if (items == 2) + { + ReadOnlySpan encodedPath = stream.DecodeByteArraySpan(); + (byte[] pathNibbles, bool isLeaf) = HexPrefix.FromBytes(encodedPath); + + if (isLeaf) + { + // State trie account leaf: decode account to follow the storage trie if non-empty. + if (address is null) + { + ReadOnlySpan accountRlp = stream.DecodeByteArraySpan(); + Hash256? storageRoot = DecodeAccountStorageRoot(accountRlp); + if (storageRoot is not null) + { + // The full 64-nibble path (root → this leaf) equals Keccak(accountAddress), + // which is the address key used by NodeStorage for storage trie nodes. + TreePath fullPath = path.Append(pathNibbles); + Hash256 addressHash = new Hash256(in fullPath.Path); + stack.Push((addressHash, TreePath.Empty, storageRoot)); + } + } + // Storage trie leaf: value is a storage slot — no child nodes. + } + else + { + // Extension node: single hash-referenced child, path extended by pathNibbles. + (int _, int contentLength) = stream.PeekPrefixAndContentLength(); + if (contentLength == 32) + stack.Push((address, path.Append(pathNibbles), stream.DecodeKeccak()!)); + // Inline child (< 32 bytes) is embedded in the parent — not a separate MemDb entry. + } + } + } + + private static Hash256? DecodeAccountStorageRoot(ReadOnlySpan accountRlp) + { + Rlp.ValueDecoderContext ctx = new Rlp.ValueDecoderContext(accountRlp); + Hash256 storageRoot = _accountDecoder.DecodeStorageRootOnly(ref ctx); + return storageRoot == Keccak.EmptyTreeHash ? null : storageRoot; + } +} diff --git a/src/Nethermind.Arbitrum/Execution/Stateless/StateReconstructor.cs b/src/Nethermind.Arbitrum/Execution/Stateless/StateReconstructor.cs new file mode 100644 index 000000000..19c483716 --- /dev/null +++ b/src/Nethermind.Arbitrum/Execution/Stateless/StateReconstructor.cs @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: https://github.com/NethermindEth/nethermind-arbitrum/blob/main/LICENSE.md + +using System.Collections.Concurrent; +using Autofac; +using Nethermind.Arbitrum.Arbos; +using Nethermind.Arbitrum.Config; +using Nethermind.Arbitrum.Evm; +using Nethermind.Arbitrum.Precompiles; +using Nethermind.Arbitrum.Stylus; +using Nethermind.Blockchain; +using Nethermind.Blockchain.Headers; +using Nethermind.Blockchain.Receipts; +using Nethermind.Blockchain.Tracing; +using Nethermind.Config; +using Nethermind.Consensus.Processing; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Specs; +using Nethermind.Crypto; +using Nethermind.Db; +using Nethermind.Evm; +using Nethermind.Evm.State; +using Nethermind.Evm.TransactionProcessing; +using Nethermind.Logging; +using Nethermind.State; + +namespace Nethermind.Arbitrum.Execution.Stateless; + +public class StateReconstructor : IStateReconstructor +{ + private readonly ReconstructedStateTrieStore _trieStore; + private readonly IBlockTree _blockTree; + private readonly ILifetimeScope _rootLifetimeScope; + private readonly IReceiptStorage _receiptStorage; + private readonly IEthereumEcdsa _ecdsa; + private readonly ILogManager _logManager; + private readonly ILogger _logger; + private readonly long _genesisBlockNumber; + private readonly object _reconstructionLock = new(); + + /// + /// Maximum number of state roots to keep pinned in the MemDb overlay simultaneously. + /// When exceeded, the oldest entries are evicted (their nodes dereferenced and potentially deleted). + /// + private readonly int _maxStatesPrepared; + + /// FIFO queue of pinned state roots; oldest entries are evicted when the queue exceeds . + private readonly ConcurrentQueue _preparedQueue = new(); + + public StateReconstructor( + ReconstructedStateTrieStore trieStore, + IBlockTree blockTree, + ILifetimeScope rootLifetimeScope, + IReceiptStorage receiptStorage, + IEthereumEcdsa ecdsa, + IArbitrumSpecHelper specHelper, + IArbitrumConfig arbitrumConfig, + ILogManager logManager) + { + _trieStore = trieStore; + _blockTree = blockTree; + _rootLifetimeScope = rootLifetimeScope; + _receiptStorage = receiptStorage; + _ecdsa = ecdsa; + _logManager = logManager; + _logger = logManager.GetClassLogger(); + _genesisBlockNumber = (long)specHelper.GenesisBlockNum; + _maxStatesPrepared = arbitrumConfig.ValidatorMaxStatesPrepared; + } + + /// + /// Ensures the state for the given parent header is available in the ReconstructedStateTrieStore. + /// If unavailable, walks backward to find the nearest available state and re-executes blocks forward. + /// After this call, the state root is pinned in the prepared queue (if MemDb-resident) and + /// will be kept alive until evicted by later calls. + /// + public void EnsureStateAvailable(BlockHeader targetParent) + { + Hash256 stateRoot = targetParent.StateRoot!; + + lock (_reconstructionLock) + { + // Re-check after acquiring the lock: another thread may have reconstructed while we waited. + if (_trieStore.HasRoot(stateRoot)) + { + // Pin the state root if it lives in the MemDb overlay + _trieStore.Reference(stateRoot); + + if (_logger.IsDebug) + _logger.Debug($"State already available for block {targetParent.Number} (root {stateRoot})"); + + return; + } + + if (_logger.IsInfo) + _logger.Info($"State not available for block {targetParent.Number} (root {stateRoot}), reconstructing..."); + + BlockHeader lastAvailable = FindLastAvailableState(targetParent); + // Pin the lastAvailable's state root if it lives in the MemDb overlay + _trieStore.Reference(lastAvailable.StateRoot!); + + if (_logger.IsInfo) + _logger.Info($"Found available state at block {lastAvailable.Number} (root {lastAvailable.StateRoot}), re-executing {targetParent.Number - lastAvailable.Number} blocks forward"); + + ReExecuteBlocks(lastAvailable, targetParent); + + if (!_trieStore.HasRoot(stateRoot)) + throw new InvalidOperationException($"State reconstruction failed: root {stateRoot} not available after re-execution"); + } + } + + /// + // For recreating state, this method walks backwards from the target header until it finds a header + // whose state root is available in the RecordingTrieStore or otherwise reaches genesis and throws there. + /// + private BlockHeader FindLastAvailableState(BlockHeader target) + { + BlockHeader current = target; + + while (true) + { + if (_trieStore.HasRoot(current.StateRoot!)) + return current; + + if (current.Number <= _genesisBlockNumber) + throw new InvalidOperationException($"Reached genesis (block {_genesisBlockNumber}) without finding available state while looking for block {target.Number}"); + + BlockHeader? parent = _blockTree.FindHeader(current.ParentHash!, BlockTreeLookupOptions.RequireCanonical, current.Number - 1); + if (parent is null) + throw new InvalidOperationException($"Cannot find header for block {current.Number - 1} during state reconstruction"); + + current = parent; + } + } + + private void ReExecuteBlocks(BlockHeader lastAvailable, BlockHeader targetParent) + { + long startBlock = lastAvailable.Number + 1; + long endBlock = targetParent.Number; + + IBlocksConfig blocksConfig = _rootLifetimeScope.Resolve(); + // Not necessary to write codeDB in read only given writes to it are idempotent + WorldState worldState = new( + new TrieStoreScopeProvider(_trieStore, _rootLifetimeScope.Resolve().CodeDb, _logManager), + _logManager); + + using ILifetimeScope scope = _rootLifetimeScope.BeginLifetimeScope(builder => + { + builder + .AddScoped(_ => worldState) + .AddScoped(_ => CreateReconstructionBlocksConfig(blocksConfig)) + + .AddScoped(ctx => CreateTransactionProcessor( + ctx.Resolve(), + ctx.Resolve(), + ctx.Resolve(), + ctx.Resolve(), + worldState, + ctx.Resolve())) + + .AddScoped(ctx => new BlockProcessor.BlockValidationTransactionsExecutor( + new BuildUpTransactionProcessorAdapter(ctx.Resolve()), + worldState)) + + .AddScoped(NullReceiptStorage.Instance) + .AddScoped(BlockchainProcessor.Options.NoReceipts) + .AddScoped(); + }); + + IBlockProcessor blockProcessor = scope.Resolve(); + ISpecProvider specProvider = scope.Resolve(); + + using (worldState.BeginScope(lastAvailable)) + { + Hash256 expectedParentHash = lastAvailable.Hash!; + Hash256 prevStateRoot = lastAvailable.StateRoot!; + + for (long blockNumber = startBlock; blockNumber <= endBlock; blockNumber++) + { + Block? block = _blockTree.FindBlock(blockNumber, BlockTreeLookupOptions.RequireCanonical); + if (block is null) + throw new InvalidOperationException($"Cannot find block {blockNumber} during state reconstruction"); + + if (block.ParentHash != expectedParentHash) + throw new InvalidOperationException( + $"Parent hash mismatch at block {blockNumber}: expected {expectedParentHash}, got {block.ParentHash}"); + + // SenderAddress is not persisted in block RLP — recover from receipts (fast path for + // Arbitrum internal txs which have no ECDSA signature) or from ECDSA signature. + RecoverTxSenders(block); + + Hash256 expectedBlockHash = block.Hash!; + IReleaseSpec spec = specProvider.GetSpec(block.Header); + (Block processedBlock, _) = blockProcessor.ProcessOne(block, ProcessingOptions.ForceProcessing, NullBlockTracer.Instance, spec); + + if (processedBlock.Hash != expectedBlockHash) + throw new InvalidOperationException( + $"Block hash mismatch after re-execution of block {blockNumber}: expected {expectedBlockHash}, got {processedBlock.Hash}"); + + worldState.CommitTree(block.Number); + + Hash256 currentStateRoot = processedBlock.Header.StateRoot!; + + // Pin the newly reconstructed state + _trieStore.Reference(currentStateRoot); + // Dereference the previous block's state (temporary reference only) + _trieStore.Dereference(prevStateRoot); + + prevStateRoot = currentStateRoot; + + worldState.Reset(); + + expectedParentHash = processedBlock.Hash!; + + if (_logger.IsDebug && blockNumber % 100 == 0) + _logger.Debug($"State reconstruction progress: {blockNumber - startBlock + 1}/{endBlock - startBlock + 1} blocks"); + } + } + + if (_logger.IsInfo) + _logger.Info($"State reconstruction complete: re-executed {endBlock - startBlock + 1} blocks ({startBlock} to {endBlock})"); + } + + public void DereferenceRoot(Hash256 parentStateRoot) + { + lock (_reconstructionLock) + _trieStore.Dereference(parentStateRoot); + } + + public void PreparedAddTrim(List stateRoots) + { + lock (_reconstructionLock) + { + foreach (Hash256 stateRoot in stateRoots) + _preparedQueue.Enqueue(stateRoot); + + if (_preparedQueue.Count > _maxStatesPrepared) + { + int toEvict = _preparedQueue.Count - _maxStatesPrepared; + for (int i = 0; i < toEvict; i++) + { + if (_preparedQueue.TryDequeue(out Hash256? oldStateRoot)) + _trieStore.Dereference(oldStateRoot); + } + } + } + } + + private void RecoverTxSenders(Block block) + { + TxReceipt[] receipts = _receiptStorage.Get(block); + if (block.Transactions.Length == receipts.Length) + { + for (int i = 0; i < block.Transactions.Length; i++) + block.Transactions[i].SenderAddress ??= receipts[i].Sender ?? _ecdsa.RecoverAddress(block.Transactions[i]); + } + else + { + for (int i = 0; i < block.Transactions.Length; i++) + block.Transactions[i].SenderAddress ??= _ecdsa.RecoverAddress(block.Transactions[i]); + } + } + + private ITransactionProcessor CreateTransactionProcessor( + IArbitrumSpecHelper arbitrumSpecHelper, + IWasmStore wasmStore, + ISpecProvider specProvider, + IArbosVersionProvider arbosVersionProvider, + IWorldState state, + IHeaderFinder headerFinder) + { + BlockhashProvider blockhashProvider = new(new BlockhashCache(headerFinder, _logManager), state, _logManager); + ArbitrumVirtualMachine vm = new(arbitrumSpecHelper, blockhashProvider, wasmStore, specProvider, _logManager); + + return new ArbitrumTransactionProcessor( + BlobBaseFeeCalculator.Instance, specProvider, state, wasmStore, vm, _logManager, + new ArbitrumCodeInfoRepository(new CodeInfoRepository(state, new EthereumPrecompileProvider()), arbosVersionProvider)); + } + + private static BlocksConfig CreateReconstructionBlocksConfig(IBlocksConfig blocksConfig) + => new() + { + TargetBlockGasLimit = blocksConfig.TargetBlockGasLimit, + MinGasPrice = blocksConfig.MinGasPrice, + RandomizedBlocks = blocksConfig.RandomizedBlocks, + ExtraData = blocksConfig.ExtraData, + SecondsPerSlot = blocksConfig.SecondsPerSlot, + SingleBlockImprovementOfSlot = blocksConfig.SingleBlockImprovementOfSlot, + PreWarmStateOnBlockProcessing = false, + CachePrecompilesOnBlockProcessing = blocksConfig.CachePrecompilesOnBlockProcessing, + PreWarmStateConcurrency = blocksConfig.PreWarmStateConcurrency, + BlockProductionTimeoutMs = blocksConfig.BlockProductionTimeoutMs, + GenesisTimeoutMs = blocksConfig.GenesisTimeoutMs, + BlockProductionMaxTxKilobytes = blocksConfig.BlockProductionMaxTxKilobytes, + GasToken = blocksConfig.GasToken, + BlockProductionBlobLimit = blocksConfig.BlockProductionBlobLimit, + BuildBlocksOnMainState = false, + }; +} diff --git a/src/Nethermind.Arbitrum/Modules/ArbitrumRpcModule.cs b/src/Nethermind.Arbitrum/Modules/ArbitrumRpcModule.cs index 1f30c18b4..c583eab88 100644 --- a/src/Nethermind.Arbitrum/Modules/ArbitrumRpcModule.cs +++ b/src/Nethermind.Arbitrum/Modules/ArbitrumRpcModule.cs @@ -79,4 +79,7 @@ public Task> MaintenanceStatus() public Task> RecordBlockCreation(RecordBlockCreationParameters parameters) => engine.RecordBlockCreation(parameters); + + public ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters) + => engine.PrepareForRecord(parameters); } diff --git a/src/Nethermind.Arbitrum/Modules/IArbitrumRpcModule.cs b/src/Nethermind.Arbitrum/Modules/IArbitrumRpcModule.cs index a49bea7fd..6c9779820 100644 --- a/src/Nethermind.Arbitrum/Modules/IArbitrumRpcModule.cs +++ b/src/Nethermind.Arbitrum/Modules/IArbitrumRpcModule.cs @@ -58,5 +58,8 @@ public interface IArbitrumRpcModule : IRpcModule [JsonRpcMethod(IsSharable = false, IsImplemented = true)] Task> RecordBlockCreation(RecordBlockCreationParameters parameters); + + [JsonRpcMethod(IsSharable = false, IsImplemented = true)] + ResultWrapper PrepareForRecord(PrepareForRecordParameters parameters); } } diff --git a/src/Nethermind.Arbitrum/Properties/configs/arbitrum-mainnet-with-validation.json b/src/Nethermind.Arbitrum/Properties/configs/arbitrum-mainnet-with-validation.json new file mode 100644 index 000000000..ba462e8ec --- /dev/null +++ b/src/Nethermind.Arbitrum/Properties/configs/arbitrum-mainnet-with-validation.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://raw.githubusercontent.com/NethermindEth/core-scripts/refs/heads/main/schemas/config.json", + "Init": { + "ChainSpecPath": "chainspec/arbitrum-mainnet.json", + "BaseDbPath": "nethermind_db/arbitrum-mainnet", + "LogFileName": "arbitrum-mainnet.log" + }, + "TxPool": { + "BlobsSupport": "Disabled" + }, + "Sync": { + "NetworkingEnabled": false, + "FastSync": true, + "SnapSync": true, + "FastSyncCatchUpHeightDelta": "10000000000", + "PivotNumber": 260000000, + "PivotHash": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "Discovery": { + "DiscoveryVersion": "V5" + }, + "JsonRpc": { + "Enabled": true, + "Port": 20545, + "EnginePort": 20551, + "UnsecureDevNoRpcAuthentication": true, + "EngineEnabledModules": ["Net", "Eth", "Subscribe", "Web3", "Arbitrum", "nitroexecution"], + "AdditionalRpcUrls": [ + "http://localhost:28551|http;ws|net;eth;subscribe;web3;client;debug" + ], + "EnabledModules": [ + "Admin", + "Clique", + "Consensus", + "Db", + "Debug", + "Deposit", + "Erc20", + "Eth", + "Evm", + "Net", + "Nft", + "Parity", + "Personal", + "Proof", + "Subscribe", + "Trace", + "TxPool", + "Vault", + "Web3", + "Arbitrum" + ] + }, + "Pruning": { + "PruningBoundary": 192, + "MaxUnpersistedBlockCount": 1000, + "TrackPastKeys": false, + "PersistHeadOnShutdown": true + }, + "Blocks": { + "SecondsPerSlot": 2, + "PreWarmStateOnBlockProcessing": false, + "BuildBlocksOnMainState": true + }, + "Metrics": { + "NodeName": "Nethermind Arbitrum One" + }, + "Merge": { + "Enabled": true + }, + "Arbitrum": { + "ValidatorMaxStatesPrepared": 1000 + }, + "Snapshot": { + "Enabled": true, + "SnapshotDirectory": "snapshot/mainnet", + "DownloadUrl": "https://arb-snapshot.nethermind.dev/arbitrum-snapshot/snapshot.zip" + } +} diff --git a/src/Nethermind.Arbitrum/Properties/configs/arbitrum-sepolia-with-validation.json b/src/Nethermind.Arbitrum/Properties/configs/arbitrum-sepolia-with-validation.json new file mode 100644 index 000000000..6d7f05c2d --- /dev/null +++ b/src/Nethermind.Arbitrum/Properties/configs/arbitrum-sepolia-with-validation.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://raw.githubusercontent.com/NethermindEth/core-scripts/refs/heads/main/schemas/config.json", + "Init": { + "ChainSpecPath": "chainspec/arbitrum-sepolia.json", + "BaseDbPath": "nethermind_db/arbitrum-sepolia", + "LogFileName": "arbitrum-sepolia.log" + }, + "TxPool": { + "BlobsSupport": "Disabled" + }, + "Sync": { + "NetworkingEnabled": false, + "FastSync": true, + "SnapSync": true, + "FastSyncCatchUpHeightDelta": "10000000000", + "PivotNumber": 26340000, + "PivotHash": "0x4aa85d310d133f30b102379cd5fbc5d5e812d962f4f1c84b3cd046cbf7ea932d" + }, + "Discovery": { + "DiscoveryVersion": "V5" + }, + "JsonRpc": { + "Enabled": true, + "Port": 20545, + "EnginePort": 20551, + "UnsecureDevNoRpcAuthentication": true, + "EngineEnabledModules": ["Net", "Eth", "Subscribe", "Web3", "Arbitrum", "nitroexecution"], + "AdditionalRpcUrls": [ + "http://localhost:28551|http;ws|net;eth;subscribe;web3;client;debug" + ], + "EnabledModules": [ + "Admin", + "Clique", + "Consensus", + "Db", + "Debug", + "Deposit", + "Erc20", + "Eth", + "Evm", + "Net", + "Nft", + "Parity", + "Personal", + "Proof", + "Subscribe", + "Trace", + "TxPool", + "Vault", + "Web3", + "Arbitrum" + ] + }, + "Pruning": { + "PruningBoundary": 192, + "MaxUnpersistedBlockCount": 1000, + "TrackPastKeys": false, + "PersistHeadOnShutdown": true + }, + "Blocks": { + "SecondsPerSlot": 2, + "PreWarmStateOnBlockProcessing": false, + "BuildBlocksOnMainState": true + }, + "Metrics": { + "NodeName": "Nethermind Arbitrum Sepolia" + }, + "Merge": { + "Enabled": true + }, + "Arbitrum": { + "BlockProcessingTimeout": 10000, + "RebuildLocalWasm": "auto", + "ValidatorMaxStatesPrepared": 1000 + }, + "VerifyBlockHash": { + "Enabled": false, + "VerifyEveryNBlocks": 10000, + "ArbNodeRpcUrl": "https://sepolia-rollup.arbitrum.io/rpc" + } +}