Skip to content

Conversation

AnnaShaleva
Copy link
Member

@AnnaShaleva AnnaShaleva commented Sep 5, 2025

Description

Close #1526.

It's the initial version that has a couple of TODOs, so the work is still in progress, but @neo-project/core may perform the initial review of this PR. I tried to introduce as less changes to the existing codebase as possible.

This version is tested on NeoBench with StateService plugin enabled from the genesis, Faun enabled from non-zero height and using homogeneous C# nodes setup: at least blocks are being accepted, Faun transition happens seamlessly and state root is being included into header starting from Faun. I'll do more tests once implementation is finished and I'll notify @superboyiii once this PR is ready for NGD testing.

TODO:

  • Allow to enable StateService from some arbitrary height to avoid chain resync (the code is written, but it doesn't work properly, need to fix it and test).
  • Unit tests (in progress).
  • NeoBench tests with heterogeneous setup (need to polish Make staterootinheader a proper block version 1 nspcc-dev/neo-go#3566 a bit for that).
  • Reduce DBFT->StateService->RPCServer dependency to DBFT->StateService, probably split StateService into two plugins.

Type of change

  • New feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Unit tests (in progress).
  • NeoBench homogeneous tests.
  • NeoBench heterogeneous tests.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@AnnaShaleva AnnaShaleva marked this pull request as draft September 5, 2025 17:55
AnnaShaleva added a commit to nspcc-dev/neo-bench that referenced this pull request Sep 5, 2025
Required to test nspcc-dev/neo-go#3566
and neo-project/neo#4161.

Includes nspcc-dev/neo-go#4001.

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
sizeof(uint) + // Index
sizeof(byte) + // PrimaryIndex
UInt160.Length + // NextConsensus
(version is (uint)BlockVersion.Zero ? 0 : UInt256.Length) + // PrevStateRoot
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(version is (uint)BlockVersion.Zero ? 0 : UInt256.Length) + // PrevStateRoot
(version == 0 ? 0 : UInt256.Length) + // PrevStateRoot

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

{
if (primaryIndex >= settings.ValidatorsCount)
return false;
if (version != (uint)(settings.IsHardforkEnabled(Hardfork.HF_Faun, Index) ? BlockVersion.One : BlockVersion.Zero))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very sloppy and makes more work for others to maintain. At least create a private method for this (uint)(settings.IsHardforkEnabled(Hardfork.HF_Faun, Index) ? BlockVersion.One : BlockVersion.Zero) code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Hashes.Length
]);
};
if (Header.Version == (uint)BlockVersion.One)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we add them at the end, we don't break a smart contract that use Hashes.Length in previous version

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current order has some logic behind it and is also more clear wrt hashable/non-hashable parts, but the concern is valid. We can check current Ledger callers, maybe it's not a problem practically and hardforks are hardforks for a reason. The other way to solve this is to have a separate call to return v1 structure.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for now, for simplicity, everything should be added in the end I think

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, I worry less about the communication protocol itself because it will not cause problems since nodes will be aligned with the format.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixed yet, we need to come to an agreement. To me, moving PrevStateRoot to the end of the Header won't help much, because there's always a set of transactions attached to the Block, so we still need to update dependent tools anyway.

"UnhandledExceptionPolicy": "StopNode"
}
},
"Dependency": [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why add this extra dependency ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because dBFT should have an access to stateroot at every block, and stateroot is managed by StateService plugin.

if (system.Settings.Network != StateServiceSettings.Default.Network) return;
StateStore.Singleton.UpdateLocalStateRootSnapshot(block.Index,

// Rebuild MPT from scratch on node bootstrap if StateService plugin is not up-to-date wrt the current block height.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is not clear to me, why rebuild here and why the plugin would be out of sync

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's to avoid node resynchronization for existing CNs. We can skip doing this (frankly, It'd be even easier for us), but then CNs would need to enable the plugin and restart from genesis.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the plugin to restart, not the whole node. Maybe we need this feature

Copy link
Member Author

@AnnaShaleva AnnaShaleva Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the plugin to restart,

This may be moved to a separate issue, but for now it's implemented as is. I'm open to suggestions on a place where we can move this functionality, because right now it's not a proper place for it (right now we rebuild MPT after block processing first time when the node is started with StateService plugin in fact).

@AnnaShaleva
Copy link
Member Author

AnnaShaleva commented Sep 19, 2025

@neo-project/core let's review one more time, the implementation is finished and passed my tests with NeoBench and mixed Go+C# CNs privnet:

anna@kiwi:~/Documents/GitProjects/nspcc-dev/neo-bench$ make start.MixedFourNodesGoRPC50rate 
./runner.sh --nodes mixed -d "MixedGoRPC4x1" -m rate -q 50 -z 5m -t 30s
make[1]: Entering directory '/home/anna/Documents/GitProjects/nspcc-dev/neo-bench'
=> Stop environment
=> Stop Bench process
bench: no process found
=> Generate configurations for single-node and four-nodes networks from templates
+ cd ./cmd
+ go run ./config/ --go-template go.protocol.template.yml --go-db leveldb --sharp-template sharp.protocol.template.yml --sharp-db LevelDBStore --msPerBlock 0
make[1]: Leaving directory '/home/anna/Documents/GitProjects/nspcc-dev/neo-bench'
WARN[0000] a network with name neo_go_network exists but was not created for project "ir".
Set `external: true` to use an existing network 
[+] Creating 7/7
 ✔ Container neo_go_node_four   Created                                                                                                                                                  0.0s 
 ✔ Container neo-cli-node-two   Created                                                                                                                                                  0.0s 
 ✔ Container neo_go_node_three  Created                                                                                                                                                  0.0s 
 ✔ Container neo-cli-node-one   Created                                                                                                                                                  0.0s 
 ✔ Container ir-healthy-1       Created                                                                                                                                                  0.0s 
 ✔ Container go-node            Created                                                                                                                                                  0.0s 
 ✔ Container ir-node_healthy-1  Created                                                                                                                                                  0.0s 
[+] Running 7/7
 ✔ Container neo-cli-node-two   Healthy                                                                                                                                                  5.6s 
 ✔ Container neo_go_node_four   Healthy                                                                                                                                                  5.6s 
 ✔ Container neo_go_node_three  Healthy                                                                                                                                                  5.6s 
 ✔ Container neo-cli-node-one   Healthy                                                                                                                                                  5.6s 
 ✔ Container ir-healthy-1       Started                                                                                                                                                  0.1s 
 ✔ Container go-node            Healthy                                                                                                                                                  5.6s 
 ✔ Container ir-node_healthy-1  Started                                                                                                                                                  0.1s 
2025/09/17 15:39:59 Used [go-node:20331] rpc addresses
2025/09/17 15:39:59 Run benchmark for MixedGoRPC4x1 :: NEO-GO
2025/09/17 15:40:08 Read 3000000 txs from /dump.txs
2025/09/17 15:40:12 Done 3.919597779s
2025/09/17 15:40:12 Init 30 workers with 50 QPS / 5m0s time limit (3000000 txs will try to send)
2025/09/17 15:40:12 Prepare chain for benchmark
2025/09/17 15:40:12 Determined validators count: 4
2025/09/17 15:40:12 Sending NEO and GAS transfer tx
2025/09/17 15:40:18 CPU: 0.609%, Mem: 281.340MB
2025/09/17 15:40:21 Contract hash: ceb508fc02abc2dc27228e21976699047bbbcce0
2025/09/17 15:40:21 Sending contract deploy tx
2025/09/17 15:40:21 Contract was persisted: false
2025/09/17 15:40:22 Contract was persisted: false
2025/09/17 15:40:22 Contract was persisted: false
2025/09/17 15:40:23 Contract was persisted: false
2025/09/17 15:40:23 Contract was persisted: false
2025/09/17 15:40:24 Contract was persisted: false
2025/09/17 15:40:24 Contract was persisted: false
2025/09/17 15:40:25 Contract was persisted: false
2025/09/17 15:40:25 Contract was persisted: false
2025/09/17 15:40:26 Contract was persisted: false
2025/09/17 15:40:26 Contract was persisted: true
2025/09/17 15:40:26 fetch current block count
2025/09/17 15:40:26 Waiting for an empty block to be processed
2025/09/17 15:40:28 CPU: 1.791%, Mem: 292.234MB
2025/09/17 15:40:38 CPU: 1.159%, Mem: 297.473MB
2025/09/17 15:40:41 Started test from block = 7 at unix time = 1758123641473
2025/09/17 15:40:42 empty block: 7
2025/09/17 15:40:46 #8: 210 transactions in 5011 ms - 41.907803 tps
2025/09/17 15:40:48 CPU: 2.402%, Mem: 310.402MB
2025/09/17 15:40:58 CPU: 1.826%, Mem: 349.316MB
2025/09/17 15:41:01 #9: 750 transactions in 15071 ms - 49.764448 tps
2025/09/17 15:41:06 #10: 270 transactions in 5019 ms - 53.795577 tps
2025/09/17 15:41:08 CPU: 3.052%, Mem: 282.375MB
2025/09/17 15:41:18 CPU: 1.012%, Mem: 307.262MB
2025/09/17 15:41:21 #11: 750 transactions in 15070 ms - 49.767750 tps
2025/09/17 15:41:26 #12: 240 transactions in 5020 ms - 47.808765 tps
2025/09/17 15:41:28 CPU: 1.639%, Mem: 311.273MB
2025/09/17 15:41:38 CPU: 1.018%, Mem: 301.078MB
2025/09/17 15:41:41 #13: 750 transactions in 15090 ms - 49.701789 tps
...

@superboyiii the following minimum functionality should be tested:

  1. Consensus with Faun enabled at some height. The expected behaviour is: starting from HFFaun block version should be switched to 1 and PrevStateRoot field should be added to the block's header. PrepareRequest and corresponding part of Recovery dBFT messages should be extended with the same field. I already did this test using NeoBench, but it would be good if you can re-check.
  2. I tested compatibility with NeoGo implementation via mixed 4-nodes privnet (2 Go + 2 C# CNs) using NeoBench (.docker: enable Faun fork nspcc-dev/neo-bench#200). I used Make staterootinheader a proper block version 1 nspcc-dev/neo-go#3566 as Go node implementation and https://github.com/neo-project/neo/tree/bench-faun branch as a C# node implementation (some new Faun-dependent functionality is moved to Gorgon because we need state of native contracts in Faun to be compatible between Go/C#, but some of newly-added functionality is not yet ported to Go node, ref. core: port StdLib's hexEncode, hexDecode and Policy's getBlockedAccounts APIs nspcc-dev/neo-go#4004).
  3. A separate test-case is: start CNs without StateService plugin, let them accept some blocks. Then shutdown CNs, add StateService plugin to every CN, schedule Faun to some further height and restart nodes using an existing database. Nodes are expected to build MPT at the current height based on the existing contract storage state before next block persist. I did this test locally using mainnet data, the resulted stateroot matches the expected one, but I'd ask you to recheck. This functionality is needed because we'd like to avoid full DB resynchronisation on upgrade for those nodes that don't have StateService plugin enabled.

Comment on lines 131 to 132
// TODO: the following exception must be critical for the node functioning, need to
// refactor this code to stop the node if it happens.
if (nextH is not null && root != nextH.PrevStateRoot)
throw new InvalidOperationException($"Stateroot mismatch at {block.Index}: expected {nextH.PrevStateRoot}, got {root}");
Copy link
Member Author

@AnnaShaleva AnnaShaleva Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shargon I need some help with this. This exception must stop the block processing because it means that local stateroot doesn't match the one included into the block by CNs (so either block is invalid or local MPT is invalid, both situations are critical). But I'm not sure what's the best way to implement it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An exception will deny the service, But maybe we can create a new exception, catch it, and revert the snapshot

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in this case DoS is exactly what we want. We know the state should be A at height H, if we have state B at the same height something is wrong and node can't be used reliably.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roman is right, this is exactly the purpose of this pr.

}
},
"Dependency": [
"StateService"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@neo-project/core There's a pitfall that may be moved to a separate issue: dBFT needs StateService because dBFT should have an access to stateroots, and stateroots are managed by StateService plugin. However, StateService plugin requires RPCServer plugin to be installed because of RPC method extensions. So in other words, dBFT requires RPC server which is not a proper dependency for dBFT.

We may split StateService plugin functionality into two plugins: one part will manage stateroots, another part will expose additional RPC methods. Let me know if you agree or have other suggestions on how to solve this issue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref. #4190.

@AnnaShaleva AnnaShaleva marked this pull request as ready for review September 19, 2025 00:28
Comment on lines +129 to +133
if (Header.Version == (uint)BlockVersion.V1)
block.Add(Header.PrevStateRoot.ToArray());

// Block properties
block.Add(Hashes.Length);
Copy link
Member

@shargon shargon Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still thinking that if we append at the end, we don't affect SC that checks the Hashes.Length without check the version

NativeContract.NEO.ComputeNextBlockValidators(Snapshot, neoSystem.Settings) :
NativeContract.NEO.GetNextBlockValidators(Snapshot, neoSystem.Settings.ValidatorsCount))
NativeContract.NEO.GetNextBlockValidators(Snapshot, neoSystem.Settings.ValidatorsCount)),
PrevStateRoot = isFaun ? StateService.StatePlugin.GetStateRootHash(height) : null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this null here cause any issue? is the codebase robust enough to tolerate a null prevstateroot?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's checked.

h.Version = (uint)BlockVersion.V1;
// blockbase 4 + 64 + 1 + 32 + 4 + 4 + 20 + 4 + 32
// header 1
Assert.AreEqual(145, uut.Size); // 105 + nonce + prevHash
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still asserts on uut.Size after
creating the V1 header in h, so the test always compares against the V0 size (113) and fails.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, UTs fixes are still in progress.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

json["nextconsensus"] = nextConsensus.ToAddress(settings.AddressVersion);
json["witnesses"] = new JArray(Witness.ToJson());
if (version == (uint)BlockVersion.V1)
json["previousstateroot"] = new JArray(prevStateRoot.ToString());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this an array?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed.

Close #1526.

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
Do not require StateService plugin to be enabled from genesis block.
Node can build MPT based on the current contract storage state from
scratch at the arbitrary height.

This feature is required to avoid chain resync from genesis for those
nodes who don't have StateService plugin enabled.

Signed-off-by: Anna Shaleva <shaleva.ann@nspcc.ru>
@AnnaShaleva
Copy link
Member Author

AnnaShaleva commented Sep 26, 2025

@neo-project/core I think I need your help with unit-tests: GitHub testing jobs are failing for dBFT plugin (probably due to timeout):

   [coverlet] _mapping file name: 'CoverletSourceRootsMapping_Neo.Plugins.DBFTPlugin.Tests'
  Run tests: '/home/runner/work/neo/neo/bin/tests/Neo.Plugins.DBFTPlugin.Tests/net9.0/Neo.Plugins.DBFTPlugin.Tests.dll' [net9.0|x64]
Error: The operation was canceled.

And also locally I have Neo.UnitTests hanging for a long time, I'm not sure how to locate the problem test:
image

@AnnaShaleva
Copy link
Member Author

AnnaShaleva commented Sep 26, 2025

@Jim8y @roman-khimov Just for the record, I just checked what is the time of rebuilding MPT on the current Mainnet at height 8060917 (current height), it takes 348277ms on my local machine (~6min) which is pretty much affordable. So users may safely install StateService plugin without full node resynchronisation.

Just for the record, I measured it with this change:

diff --git a/src/Plugins/StateService/StatePlugin.cs b/src/Plugins/StateService/StatePlugin.cs
index 788934c89..1417b7c06 100644
--- a/src/Plugins/StateService/StatePlugin.cs
+++ b/src/Plugins/StateService/StatePlugin.cs
@@ -111,6 +111,7 @@ public override void Dispose()
                 var stateRoot = stateSnapshot.GetStateRoot(persistedHeight);
                 if (stateRoot is null)
                 {
+                    var watch = System.Diagnostics.Stopwatch.StartNew();
                     using var coreSnapshot = system.GetSnapshotCache();
                     // TODO: this will only work with completely empty MPT. If there's some out-of-date state,
                     // then need to rebuild the trie from empty root.
@@ -120,6 +121,10 @@ public override void Dispose()
                         .Select(si => new KeyValuePair<StorageKey, DataCache.Trackable>(si.Key, new DataCache.Trackable(si.Value, TrackState.Added)))
                         .ToList());
                     StateStore.Singleton.UpdateLocalStateRoot(persistedHeight);
+                    watch.Stop();
+                    var elapsedMs = watch.ElapsedMilliseconds;
+                    Console.WriteLine($"###### REBUILDING MPT for ${persistedHeight}: {elapsedMs}");
+                    Utility.Log(nameof(StatePlugin), LogLevel.Warning, $"###### REBUILDING MPT for ${persistedHeight}: {elapsedMs}");
                 }
             }

The resulting log is (I checked it two times, for small height and for the full chain):

 anna@kiwi:~/Documents/GitProjects/nspcc-dev/neo-bench/neo-cli-one-with_log$ cat Logs/StatePlugin/2025-09-26.log 
[Warning][09:12:06.519] ###### REBUILDING MPT for $1904: 19
[Warning][13:44:36.018] ###### REBUILDING MPT for $8060917: 348277

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants