diff --git a/src/Nethermind/Nethermind.Core.Test/Container/OrderedComponentsTests.cs b/src/Nethermind/Nethermind.Core.Test/Container/OrderedComponentsTests.cs index 4f490effa6a1..0ff3ba57978c 100644 --- a/src/Nethermind/Nethermind.Core.Test/Container/OrderedComponentsTests.cs +++ b/src/Nethermind/Nethermind.Core.Test/Container/OrderedComponentsTests.cs @@ -40,6 +40,20 @@ public void TestAddFirst() Assert.That(ctx.Resolve().Select(item => item.Name), Is.EqualTo(["1", "2", "3"])); } + [Test] + public void TestRemove() + { + // RemoveOrderedComponents drops every component of the given concrete type, keeping the rest in order + using IContainer ctx = new ContainerBuilder() + .AddLast(_ => new Item("1")) + .AddLast(_ => new OtherItem("2")) + .AddLast(_ => new Item("3")) + .RemoveOrderedComponents() + .Build(); + + Assert.That(ctx.Resolve().Select(item => item.Name), Is.EqualTo(["2"])); + } + [Test] public void TestDisallowIndividualRegistration() { @@ -112,6 +126,7 @@ protected override void Load(ContainerBuilder builder) => } private interface IItem { string Name { get; } } private record Item(string Name) : IItem; + private record OtherItem(string Name) : IItem; private class CompositeItem(IItem[] items) : IItem { public IItem[] Items { get; } = items; diff --git a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNetworkModule.cs b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNetworkModule.cs index 956dcd4060a8..bcb0fd208b37 100644 --- a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNetworkModule.cs +++ b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNetworkModule.cs @@ -2,13 +2,9 @@ // SPDX-License-Identifier: LGPL-3.0-only using Autofac; -using Nethermind.Blockchain.Synchronization; using Nethermind.Consensus; using Nethermind.Logging; -using Nethermind.Network; using Nethermind.Network.Config; -using Nethermind.Network.Contract.P2P; -using Nethermind.Stats.Model; namespace Nethermind.Core.Test.Modules; @@ -21,25 +17,6 @@ protected override void Load(ContainerBuilder builder) builder .AddSingleton(Policy.FullGossip) - // TODO: LastNStateRootTracker - - .AddAdvance(cfg => - { - cfg - .As() - .SingleInstance() - .OnActivating((m) => - { - ProtocolsManager protocolManager = m.Instance; - ISyncConfig syncConfig = m.Context.Resolve(); - - if (syncConfig.SnapServingEnabled == true || syncConfig.SnapSync) - { - protocolManager.AddSupportedCapability(new Capability(Protocol.Snap, 1)); - } - }); - }) - // Some config migration .AddDecorator((ctx, networkConfig) => { diff --git a/src/Nethermind/Nethermind.Core/Container/OrderedComponents.cs b/src/Nethermind/Nethermind.Core/Container/OrderedComponents.cs index 35d74bd14e42..d74c252f962d 100644 --- a/src/Nethermind/Nethermind.Core/Container/OrderedComponents.cs +++ b/src/Nethermind/Nethermind.Core/Container/OrderedComponents.cs @@ -1,18 +1,21 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using System.Collections.Generic; namespace Nethermind.Core.Container; public class OrderedComponents { - private IList _components = []; + private readonly List _components = []; public IEnumerable Components => _components; public void AddLast(T item) => _components.Add(item); public void AddFirst(T item) => _components.Insert(0, item); + public void RemoveAll(Predicate match) => _components.RemoveAll(match); + public void Clear() => _components.Clear(); } diff --git a/src/Nethermind/Nethermind.Core/Container/OrderedComponentsContainerBuilderExtensions.cs b/src/Nethermind/Nethermind.Core/Container/OrderedComponentsContainerBuilderExtensions.cs index 8a9b10be21cf..fd003a3c441c 100644 --- a/src/Nethermind/Nethermind.Core/Container/OrderedComponentsContainerBuilderExtensions.cs +++ b/src/Nethermind/Nethermind.Core/Container/OrderedComponentsContainerBuilderExtensions.cs @@ -76,6 +76,23 @@ public static ContainerBuilder AddCompositeOrderedComponents(this return builder; } + /// + /// Remove all previously registered ordered components of type for . + /// Useful when a plugin replaces a default component instead of layering on top of it + /// (e.g., XDC dropping the default eth/68 capability resolver). + /// + /// + /// Like , this relies on the removing decorator being registered + /// after the component it targets, which holds when a plugin module loads after the core module that + /// registered the default. + /// + public static ContainerBuilder RemoveOrderedComponents(this ContainerBuilder builder) where TImpl : T => + builder.AddDecorator>((_, orderedComponents) => + { + orderedComponents.RemoveAll(static item => item is TImpl); + return orderedComponents; + }); + /// /// Clear all previously registered ordered components for . /// Useful when a plugin needs to disable all ordered policies (e.g., Hive). diff --git a/src/Nethermind/Nethermind.Init/Modules/NetworkModule.cs b/src/Nethermind/Nethermind.Init/Modules/NetworkModule.cs index 94222454c1b8..3e1cc752a42d 100644 --- a/src/Nethermind/Nethermind.Init/Modules/NetworkModule.cs +++ b/src/Nethermind/Nethermind.Init/Modules/NetworkModule.cs @@ -67,7 +67,8 @@ protected override void Load(ContainerBuilder builder) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddFirst() + .AddLast() // Handshake .AddMessageSerializer() diff --git a/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs b/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs index dc15b6575a40..0e2ad3c5822d 100644 --- a/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs +++ b/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs @@ -15,10 +15,8 @@ using Nethermind.Logging; using Nethermind.Network; using Nethermind.Network.Config; -using Nethermind.Network.Contract.P2P; using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.Rlpx; -using Nethermind.Stats.Model; using Nethermind.Synchronization; using Nethermind.Synchronization.Peers; @@ -58,7 +56,6 @@ public class InitializeNetwork : IStep private readonly IEnode _enode; private readonly INethermindPlugin[] _plugins; private readonly Lazy _protocolsManager; - private readonly Lazy _snapCapabilitySwitcher; private readonly NodeSourceToDiscV4Feeder _enrDiscoveryAppFeeder; private readonly ISyncConfig _syncConfig; @@ -83,7 +80,6 @@ public InitializeNetwork( IEnode enode, INethermindPlugin[] plugins, Lazy protocolsManager, - Lazy snapCapabilitySwitcher, INetworkConfig networkConfig, ISyncConfig syncConfig, IInitConfig initConfig, @@ -104,7 +100,6 @@ ILogManager logManager _enode = enode; _plugins = plugins; _protocolsManager = protocolsManager; - _snapCapabilitySwitcher = snapCapabilitySwitcher; _networkConfig = networkConfig; _syncConfig = syncConfig; _initConfig = initConfig; @@ -151,12 +146,6 @@ await InitPeer().ContinueWith(initPeerTask => } }); - if (_syncConfig.SnapSync && _syncConfig.SnapServingEnabled != true) - { - _snapCapabilitySwitcher.Value.EnableSnapCapabilityUntilSynced(); - } - else if (_logger.IsDebug) _logger.Debug("Skipped enabling snap capability"); - if (cancellationToken.IsCancellationRequested) { return; @@ -258,12 +247,9 @@ private Task StartSync() protected virtual async Task InitPeer() { - IProtocolsManager protocolsManager = _protocolsManager.Value; + // Force creation so the protocols manager subscribes to session events before the RLPx listener starts. + _ = _protocolsManager.Value; - if (_syncConfig.SnapServingEnabled == true) - { - protocolsManager.AddSupportedCapability(new Capability(Protocol.Snap, 1)); - } if (!_networkConfig.DisableDiscV4DnsFeeder) { // Feed some nodes into discoveryApp in case all bootnodes is faulty. @@ -275,7 +261,7 @@ protected virtual async Task InitPeer() await plugin.InitNetworkProtocol(); } - // Capabilities must be finalized before the RLPx listener accepts peers. Otherwise + // Capabilities must be resolved before the RLPx listener accepts peers. Otherwise // early sessions can negotiate only the default ETH version and never upgrade. await _rlpxPeer.Init(); diff --git a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/TestRpcBlockchain.cs b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/TestRpcBlockchain.cs index 42031d598011..8d7d2b4f3ab5 100644 --- a/src/Nethermind/Nethermind.JsonRpc.Test/Modules/TestRpcBlockchain.cs +++ b/src/Nethermind/Nethermind.JsonRpc.Test/Modules/TestRpcBlockchain.cs @@ -233,6 +233,7 @@ await base.Build(builder => Substitute.For(), Substitute.For(), Array.Empty(), + [new DefaultP2PCapabilityResolver()], LimboLogs.Instance ); diff --git a/src/Nethermind/Nethermind.Merge.AuRa/AuRaMergePlugin.cs b/src/Nethermind/Nethermind.Merge.AuRa/AuRaMergePlugin.cs index d66e86961909..bd543014d705 100644 --- a/src/Nethermind/Nethermind.Merge.AuRa/AuRaMergePlugin.cs +++ b/src/Nethermind/Nethermind.Merge.AuRa/AuRaMergePlugin.cs @@ -17,6 +17,7 @@ using Nethermind.Consensus.Withdrawals; using Nethermind.Config; using Nethermind.Core; +using Nethermind.Core.Container; using Nethermind.Core.Specs; using Nethermind.Evm.TransactionProcessing; using Nethermind.Logging; @@ -25,6 +26,7 @@ using Nethermind.Merge.Plugin; using Nethermind.Merge.Plugin.BlockProduction; using Nethermind.Merge.Plugin.Handlers; +using Nethermind.Network; using Nethermind.Specs.ChainSpecStyle; namespace Nethermind.Merge.AuRa @@ -80,6 +82,8 @@ public class AuRaMergeModule : Module protected override void Load(ContainerBuilder builder) => builder .AddModule(new BaseMergePluginModule()) + .AddLast() + // Aura (non merge) use `BlockProducerStarter` directly. .AddSingleton() diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/MergeP2PCapabilityResolverTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/MergeP2PCapabilityResolverTests.cs new file mode 100644 index 000000000000..a8c9ced70414 --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/MergeP2PCapabilityResolverTests.cs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using Nethermind.Consensus; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Merge.Plugin.Test; + +[Parallelizable(ParallelScope.All)] +public class MergeP2PCapabilityResolverTests +{ + [TestCase(false, false, false, TestName = "Pre-merge: not advertised")] + [TestCase(true, false, true, TestName = "Transition finished (post-merge restart): advertised")] + [TestCase(false, true, true, TestName = "Live merge: terminal block reached before transition finished: advertised")] + [TestCase(true, true, true, TestName = "Both: advertised")] + public void Resolve_advertises_post_merge_eth_capabilities_once_post_merge(bool transitionFinished, bool hasReachedTerminalBlock, bool expected) + { + IPoSSwitcher poSSwitcher = Substitute.For(); + poSSwitcher.TransitionFinished.Returns(transitionFinished); + poSSwitcher.HasEverReachedTerminalBlock().Returns(hasReachedTerminalBlock); + using MergeP2PCapabilityResolver resolver = new(poSSwitcher); + + HashSet capabilities = []; + resolver.Resolve(capabilities); + + Assert.That(capabilities.Contains(new Capability(Protocol.Eth, 69)), Is.EqualTo(expected)); + Assert.That(capabilities.Contains(new Capability(Protocol.Eth, 70)), Is.EqualTo(expected)); + Assert.That(capabilities.Contains(new Capability(Protocol.Eth, 71)), Is.EqualTo(expected)); + } + + [Test] + public void Raises_Changed_when_terminal_block_reached() + { + IPoSSwitcher poSSwitcher = Substitute.For(); + using MergeP2PCapabilityResolver resolver = new(poSSwitcher); + + bool changed = false; + resolver.Changed += () => changed = true; + + poSSwitcher.TerminalBlockReached += Raise.Event(); + + Assert.That(changed, Is.True); + } +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin.Test/MergePluginTests.cs b/src/Nethermind/Nethermind.Merge.Plugin.Test/MergePluginTests.cs index 4919ef8f0541..d5d3336f3443 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin.Test/MergePluginTests.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin.Test/MergePluginTests.cs @@ -21,13 +21,11 @@ using Nethermind.Logging; using Nethermind.Merge.Plugin.BlockProduction; using Nethermind.Network; -using Nethermind.Network.Contract.P2P; using Nethermind.Runner.Ethereum.Modules; using Nethermind.Runner.Test.Ethereum; using Nethermind.Serialization.Json; using Nethermind.Specs.ChainSpecStyle; using Nethermind.Specs.Test.ChainSpecStyle; -using Nethermind.Stats.Model; using NUnit.Framework; using NSubstitute; @@ -198,48 +196,6 @@ public async Task Initializes_correctly() Assert.That(blockProducer, Is.InstanceOf()); } - [Test] - public async Task InitNetworkProtocol_adds_post_merge_eth_capabilities_when_transition_finished() - { - IPoSSwitcher poSSwitcher = Substitute.For(); - poSSwitcher.TransitionFinished.Returns(true); - - await using IContainer container = BuildContainer(configure: builder => builder - .RegisterInstance(poSSwitcher) - .As()); - INethermindApi api = container.Resolve(); - await _consensusPlugin!.Init(api); - await _plugin.Init(api); - - api.ProtocolsManager!.ClearReceivedCalls(); - await _plugin.InitNetworkProtocol(); - - AssertPostMergeEthCapabilitiesAdded(api); - } - - [Test] - public async Task InitNetworkProtocol_delays_post_merge_eth_capabilities_until_terminal_block() - { - IPoSSwitcher poSSwitcher = Substitute.For(); - poSSwitcher.TransitionFinished.Returns(false); - - await using IContainer container = BuildContainer(configure: builder => builder - .RegisterInstance(poSSwitcher) - .As()); - INethermindApi api = container.Resolve(); - await _consensusPlugin!.Init(api); - await _plugin.Init(api); - - api.ProtocolsManager!.ClearReceivedCalls(); - await _plugin.InitNetworkProtocol(); - - api.ProtocolsManager!.DidNotReceive().AddSupportedCapability(Arg.Any()); - - poSSwitcher.TerminalBlockReached += Raise.Event(); - - AssertPostMergeEthCapabilitiesAdded(api); - } - [Test] public async Task Init_registers_gas_limit_calculator_for_testing_rpc_module() { @@ -301,13 +257,4 @@ public async Task InitDisableJsonRpcUrlWithNoEngineUrl() Assert.That(jsonRpcConfig.EnabledModules, Is.Empty); Assert.That(jsonRpcConfig.AdditionalRpcUrls, Is.EqualTo(new[] { "http://localhost:8551|http;ws|net;eth;subscribe;web3;engine;client" })); } - - private static void AssertPostMergeEthCapabilitiesAdded(INethermindApi api) - { - IProtocolsManager protocolsManager = api.ProtocolsManager!; - - protocolsManager.Received(1).AddSupportedCapability(new Capability(Protocol.Eth, 69)); - protocolsManager.Received(1).AddSupportedCapability(new Capability(Protocol.Eth, 70)); - protocolsManager.Received(1).AddSupportedCapability(new Capability(Protocol.Eth, 71)); - } } diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergeP2PCapabilityResolver.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergeP2PCapabilityResolver.cs new file mode 100644 index 000000000000..f5aac403231e --- /dev/null +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergeP2PCapabilityResolver.cs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Consensus; +using Nethermind.Network; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; + +namespace Nethermind.Merge.Plugin; + +/// +/// Advertises the post-merge eth/69, eth/70 and eth/71 capabilities once the node is operating post-merge — +/// i.e. the merge transition has finished or the terminal PoW block has been reached. +/// +public class MergeP2PCapabilityResolver : IP2PCapabilityResolver, IDisposable +{ + private readonly IPoSSwitcher _poSSwitcher; + + public event Action? Changed; + + public MergeP2PCapabilityResolver(IPoSSwitcher poSSwitcher) + { + _poSSwitcher = poSSwitcher; + _poSSwitcher.TerminalBlockReached += OnTerminalBlockReached; + } + + public void Resolve(ISet capabilities) + { + // TransitionFinished alone is insufficient on a live TTD transition: it only flips true later in + // PoSSwitcher.ForkchoiceUpdated, which raises no event, so the TerminalBlockReached-driven cache + // rebuild would still see it false. HasEverReachedTerminalBlock() (set when TerminalBlockReached + // fires) mirrors the pre-resolver behaviour of advertising as soon as the terminal block is reached. + if (!_poSSwitcher.TransitionFinished && !_poSSwitcher.HasEverReachedTerminalBlock()) return; + + capabilities.Add(new Capability(Protocol.Eth, 69)); + capabilities.Add(new Capability(Protocol.Eth, 70)); + capabilities.Add(new Capability(Protocol.Eth, 71)); + } + + private void OnTerminalBlockReached(object? sender, EventArgs e) => Changed?.Invoke(); + + public void Dispose() => _poSSwitcher.TerminalBlockReached -= OnTerminalBlockReached; +} diff --git a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs index 2d897441155d..29fcd3eb4c8d 100644 --- a/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs +++ b/src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs @@ -18,6 +18,7 @@ using Nethermind.Consensus.Rewards; using Nethermind.Consensus.Validators; using Nethermind.Core; +using Nethermind.Core.Container; using Nethermind.Core.Crypto; using Nethermind.Core.Specs; using Nethermind.Core.Exceptions; @@ -35,7 +36,7 @@ using Nethermind.Merge.Plugin.InvalidChainTracker; using Nethermind.Merge.Plugin.SszRest; using Nethermind.Merge.Plugin.Synchronization; -using Nethermind.Network.Contract.P2P; +using Nethermind.Network; using Nethermind.Serialization.Json; using Nethermind.Specs.ChainSpecStyle; using Nethermind.State; @@ -190,37 +191,16 @@ public Task InitNetworkProtocol() if (MergeEnabled) { ArgumentNullException.ThrowIfNull(_api.SpecProvider); - ArgumentNullException.ThrowIfNull(_api.ProtocolsManager); if (_api.BlockProductionPolicy is null) throw new ArgumentException(nameof(_api.BlockProductionPolicy)); _mergeBlockProductionPolicy = new MergeBlockProductionPolicy(_api.BlockProductionPolicy); _api.BlockProductionPolicy = _mergeBlockProductionPolicy; InitializeMergeFinalization(); - - if (_poSSwitcher.TransitionFinished) - { - AddPostMergeNetworkProtocols(); - } - else - { - if (_logger.IsDebug) _logger.Debug("Delayed adding post-merge eth/* capabilities until terminal block reached"); - _poSSwitcher.TerminalBlockReached += (_, _) => AddPostMergeNetworkProtocols(); - } } return Task.CompletedTask; } - private void AddPostMergeNetworkProtocols() - { - if (_logger.IsInfo) _logger.Info("Adding eth/69 capability"); - _api.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 69)); - if (_logger.IsInfo) _logger.Info("Adding eth/70 capability"); - _api.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 70)); - if (_logger.IsInfo) _logger.Info("Adding eth/71 capability"); - _api.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 71)); - } - /// /// Hook for derived plugins (e.g. AuRaMergePlugin) to set up merge-transition lifecycle /// (e.g. disposing AuRa's finalization manager at terminal block). Default: no-op. @@ -253,6 +233,8 @@ protected override void Load(ContainerBuilder builder) => builder .AddDecorator() .AddDecorator() + .AddLast() + .AddModule(new BaseMergePluginModule()); } diff --git a/src/Nethermind/Nethermind.Network.Test/ProtocolsManagerTests.cs b/src/Nethermind/Nethermind.Network.Test/ProtocolsManagerTests.cs index ecd76a07bf41..e2e020208088 100644 --- a/src/Nethermind/Nethermind.Network.Test/ProtocolsManagerTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/ProtocolsManagerTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Collections.Generic; using System.Net; using System.Numerics; using DotNetty.Transport.Channels; @@ -68,6 +69,7 @@ public void Uses_host_disconnected_event_when_unsubscribing_on_disconnect() Substitute.For(), Substitute.For(), [], + [], LimboLogs.Instance); rlpxHost.SessionCreated += Raise.EventWith(new object(), new SessionEventArgs(session)); @@ -85,6 +87,61 @@ public void Uses_host_disconnected_event_when_unsubscribing_on_disconnect() Assert.That(session.RemovedDisconnectedHandler, Is.Null); } + [Test] + public void Advertised_capabilities_apply_resolver_additions_and_removals() + { + ProtocolsManager manager = BuildManagerWithResolvers( + new FakeCapabilityResolver(caps => caps.Add(new Capability(Protocol.Eth, 69))), + new FakeCapabilityResolver(caps => caps.Remove(new Capability(Protocol.Eth, 68)))); + + // Default eth/68 removed, eth/69 added by the resolvers. + Assert.That(manager.GetHighestProtocolVersion(Protocol.Eth), Is.EqualTo(69)); + } + + [Test] + public void Advertised_capabilities_are_cached_and_rebuilt_on_resolver_change() + { + FakeCapabilityResolver resolver = new(caps => caps.Add(new Capability(Protocol.Snap, 1))); + ProtocolsManager manager = BuildManagerWithResolvers(resolver); + + Assert.That(manager.GetHighestProtocolVersion(Protocol.Snap), Is.EqualTo(1)); + Assert.That(manager.GetHighestProtocolVersion(Protocol.Snap), Is.EqualTo(1)); + Assert.That(resolver.ResolveCount, Is.EqualTo(1), "advertised capabilities should be cached across calls"); + + resolver.RaiseChanged(); + + Assert.That(manager.GetHighestProtocolVersion(Protocol.Snap), Is.EqualTo(1)); + Assert.That(resolver.ResolveCount, Is.EqualTo(2), "cache should rebuild after a resolver signals a change"); + } + + private static ProtocolsManager BuildManagerWithResolvers(params IP2PCapabilityResolver[] resolvers) => + new( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + [], + [new DefaultP2PCapabilityResolver(), .. resolvers], + LimboLogs.Instance); + + private sealed class FakeCapabilityResolver(Action> resolve) : IP2PCapabilityResolver + { + public int ResolveCount { get; private set; } + + public void Resolve(ISet capabilities) + { + ResolveCount++; + resolve(capabilities); + } + + public event Action? Changed; + + public void RaiseChanged() => Changed?.Invoke(); + } + public class Context { private readonly int _localPort = 30312; @@ -161,6 +218,7 @@ public Context() _protocolValidator, _peerStorage, BuildProtocolHandlerFactories(), + [new DefaultP2PCapabilityResolver()], LimboLogs.Instance); } diff --git a/src/Nethermind/Nethermind.Network.Test/SnapCapabilitySwitcherTests.cs b/src/Nethermind/Nethermind.Network.Test/SnapCapabilitySwitcherTests.cs deleted file mode 100644 index 8230b5c03755..000000000000 --- a/src/Nethermind/Nethermind.Network.Test/SnapCapabilitySwitcherTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Logging; -using Nethermind.Network.Contract.P2P; -using Nethermind.Stats.Model; -using Nethermind.Synchronization.ParallelSync; -using NSubstitute; -using NUnit.Framework; - -namespace Nethermind.Network.Test; - -public class SnapCapabilitySwitcherTests -{ - [Test] - public void Dispose_ShouldUnsubscribeFromSyncModeChanges() - { - IProtocolsManager protocolsManager = Substitute.For(); - ISyncModeSelector syncModeSelector = Substitute.For(); - SnapCapabilitySwitcher snapCapabilitySwitcher = new(protocolsManager, syncModeSelector, LimboLogs.Instance); - - snapCapabilitySwitcher.EnableSnapCapabilityUntilSynced(); - snapCapabilitySwitcher.Dispose(); - syncModeSelector.Changed += Raise.EventWith(this, new SyncModeChangedEventArgs(SyncMode.StateNodes, SyncMode.Full)); - - protocolsManager.Received(1).RemoveSupportedCapability(new Capability(Protocol.Snap, 1)); - } -} diff --git a/src/Nethermind/Nethermind.Network.Test/SnapP2PCapabilityResolverTests.cs b/src/Nethermind/Nethermind.Network.Test/SnapP2PCapabilityResolverTests.cs new file mode 100644 index 000000000000..b451ed1820b5 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Test/SnapP2PCapabilityResolverTests.cs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using Nethermind.Blockchain.Synchronization; +using Nethermind.Logging; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; +using Nethermind.Synchronization.ParallelSync; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Network.Test; + +[Parallelizable(ParallelScope.All)] +public class SnapP2PCapabilityResolverTests +{ + [TestCase(true, false, SyncMode.StateNodes, true, TestName = "Serving advertises snap regardless of sync")] + [TestCase(false, true, SyncMode.StateNodes, true, TestName = "Snap-syncing state advertises snap")] + [TestCase(false, true, SyncMode.Full, false, TestName = "Snap sync finished drops snap")] + [TestCase(false, false, SyncMode.StateNodes, false, TestName = "Neither serving nor snap-syncing")] + public void Resolve_advertises_snap_when_serving_or_syncing_state(bool snapServing, bool snapSync, SyncMode currentMode, bool expected) + { + ISyncConfig syncConfig = new SyncConfig { SnapServingEnabled = snapServing, SnapSync = snapSync }; + ISyncModeSelector syncModeSelector = Substitute.For(); + syncModeSelector.Current.Returns(currentMode); + using SnapP2PCapabilityResolver resolver = new(syncConfig, syncModeSelector, LimboLogs.Instance); + + HashSet capabilities = []; + resolver.Resolve(capabilities); + + Assert.That(capabilities.Contains(new Capability(Protocol.Snap, 1)), Is.EqualTo(expected)); + } + + [TestCase(false, true, SyncMode.StateNodes, SyncMode.Full, true, TestName = "Snap sync finishing flips snap and fires Changed")] + [TestCase(false, true, SyncMode.StateNodes, SyncMode.FastBlocks, false, TestName = "Non-Full to non-Full leaves snap unchanged")] + [TestCase(true, true, SyncMode.StateNodes, SyncMode.Full, false, TestName = "Snap serving keeps snap constant across sync modes")] + [TestCase(false, false, SyncMode.StateNodes, SyncMode.Full, false, TestName = "No snap sync means no snap to toggle")] + public void Raises_Changed_only_when_snap_contribution_flips(bool snapServing, bool snapSync, SyncMode previous, SyncMode current, bool expectedFired) + { + ISyncConfig syncConfig = new SyncConfig { SnapServingEnabled = snapServing, SnapSync = snapSync }; + ISyncModeSelector syncModeSelector = Substitute.For(); + using SnapP2PCapabilityResolver resolver = new(syncConfig, syncModeSelector, LimboLogs.Instance); + + bool changed = false; + resolver.Changed += () => changed = true; + + syncModeSelector.Changed += Raise.EventWith(new SyncModeChangedEventArgs(previous, current)); + + Assert.That(changed, Is.EqualTo(expectedFired)); + } +} diff --git a/src/Nethermind/Nethermind.Network/DefaultP2PCapabilityResolver.cs b/src/Nethermind/Nethermind.Network/DefaultP2PCapabilityResolver.cs new file mode 100644 index 000000000000..7d07f58e9ffc --- /dev/null +++ b/src/Nethermind/Nethermind.Network/DefaultP2PCapabilityResolver.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; + +namespace Nethermind.Network; + +/// +/// Contributes the capabilities every node advertises by default (currently eth/68). +/// +/// +/// Registered first so that chain-specific resolvers (e.g. XDC) can remove or build on the default set. +/// +public class DefaultP2PCapabilityResolver : IP2PCapabilityResolver +{ + // The default set is static, so the cache never needs invalidating. + public event Action? Changed { add { } remove { } } + + public void Resolve(ISet capabilities) => capabilities.Add(new Capability(Protocol.Eth, 68)); +} diff --git a/src/Nethermind/Nethermind.Network/IP2PCapabilityResolver.cs b/src/Nethermind/Nethermind.Network/IP2PCapabilityResolver.cs new file mode 100644 index 000000000000..b11a185f723c --- /dev/null +++ b/src/Nethermind/Nethermind.Network/IP2PCapabilityResolver.cs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Stats.Model; + +namespace Nethermind.Network; + +/// +/// Contributes to the set of devp2p capabilities the node advertises in its P2P Hello message. +/// Implementations may add or remove capabilities based on node configuration and runtime state. +/// +/// +/// The advertised set is computed once into a cached array and reused for every session, so +/// is not on the per-session hot path. Implementations whose contribution depends on mutable state must raise +/// whenever it would change, so the cache is rebuilt. Static implementations never raise it. +/// +public interface IP2PCapabilityResolver +{ + /// Adds and/or removes this resolver's capabilities from the running set. + void Resolve(ISet capabilities); + + /// Raised when this resolver's contribution changes, to invalidate the cached advertised set. + event Action? Changed; +} diff --git a/src/Nethermind/Nethermind.Network/IProtocolsManager.cs b/src/Nethermind/Nethermind.Network/IProtocolsManager.cs index 0b4a4a3a3bc2..5a9fd071dec5 100644 --- a/src/Nethermind/Nethermind.Network/IProtocolsManager.cs +++ b/src/Nethermind/Nethermind.Network/IProtocolsManager.cs @@ -1,14 +1,10 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Stats.Model; - namespace Nethermind.Network { public interface IProtocolsManager { - void AddSupportedCapability(Capability capability); - void RemoveSupportedCapability(Capability capability); int GetHighestProtocolVersion(string protocol); } } diff --git a/src/Nethermind/Nethermind.Network/P2P/P2PProtocolInfoProvider.cs b/src/Nethermind/Nethermind.Network/P2P/P2PProtocolInfoProvider.cs index c44d9b9293ee..f02d03b4af8f 100644 --- a/src/Nethermind/Nethermind.Network/P2P/P2PProtocolInfoProvider.cs +++ b/src/Nethermind/Nethermind.Network/P2P/P2PProtocolInfoProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Nethermind.Stats.Model; namespace Nethermind.Network.P2P { @@ -10,10 +11,12 @@ public static class P2PProtocolInfoProvider { public static string DefaultCapabilitiesToString() { - IEnumerable capabilities = ProtocolsManager.DefaultCapabilities + HashSet capabilities = []; + new DefaultP2PCapabilityResolver().Resolve(capabilities); + IEnumerable formatted = capabilities .OrderBy(static x => x.ProtocolCode).ThenByDescending(static x => x.Version) .Select(static x => $"{x.ProtocolCode}/{x.Version}"); - return string.Join(",", capabilities); + return string.Join(",", formatted); } } } diff --git a/src/Nethermind/Nethermind.Network/ProtocolsManager.cs b/src/Nethermind/Nethermind.Network/ProtocolsManager.cs index 7e669637f0b9..8420903d3530 100644 --- a/src/Nethermind/Nethermind.Network/ProtocolsManager.cs +++ b/src/Nethermind/Nethermind.Network/ProtocolsManager.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using System.Threading; using Autofac.Features.AttributeFilters; using Nethermind.Config; using Nethermind.Core.Crypto; @@ -25,11 +26,6 @@ namespace Nethermind.Network { public class ProtocolsManager : IProtocolsManager, IProtocolRegistrar { - public static readonly IEnumerable DefaultCapabilities = new Capability[] - { - new(Protocol.Eth, 68), - }; - private readonly ConcurrentDictionary _syncPeers = new(); private readonly ConcurrentDictionary> _hangingSatelliteProtocols = new(); private readonly ISyncPeerPool _syncPool; @@ -41,7 +37,9 @@ public class ProtocolsManager : IProtocolsManager, IProtocolRegistrar private readonly INetworkStorage _peerStorage; private readonly ILogger _logger; private readonly IProtocolHandlerFactory[] _factories; - private readonly HashSet _capabilities = DefaultCapabilities.ToHashSet(); + private readonly IP2PCapabilityResolver[] _capabilityResolvers; + private readonly Lock _capabilitiesLock = new(); + private Capability[]? _cachedCapabilities; private readonly EventHandler _onSessionCreated; private readonly EventHandler _onSessionInitialized; private readonly SessionDisconnectedEventHandler _onSessionDisconnected; @@ -55,6 +53,7 @@ public ProtocolsManager( IProtocolValidator protocolValidator, [KeyFilter(DbNames.PeersDb)] INetworkStorage peerStorage, IProtocolHandlerFactory[] factories, + IP2PCapabilityResolver[] capabilityResolvers, ILogManager logManager) { _syncPool = syncPeerPool ?? throw new ArgumentNullException(nameof(syncPeerPool)); @@ -66,7 +65,12 @@ public ProtocolsManager( _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); // Order is already set by OrderedComponents (AddFirst/AddLast) - _factories = factories; + _factories = factories ?? throw new ArgumentNullException(nameof(factories)); + _capabilityResolvers = capabilityResolvers ?? throw new ArgumentNullException(nameof(capabilityResolvers)); + foreach (IP2PCapabilityResolver resolver in _capabilityResolvers) + { + resolver.Changed += InvalidateCapabilities; + } _onSessionCreated = SessionCreated; _onSessionInitialized = SessionInitialized; _onSessionDisconnected = SessionDisconnected; @@ -165,9 +169,10 @@ void IProtocolRegistrar.Register(ISession session, P2PProtocolHandler handler) { session.PingSender = handler; - foreach (Capability capability in _capabilities) + Capability[] capabilities = GetAdvertisedCapabilities(); + for (int i = 0; i < capabilities.Length; i++) { - session.AddSupportedCapability(capability); + session.AddSupportedCapability(capabilities[i]); } } @@ -338,20 +343,10 @@ private void AddNodeToDiscovery(ISession session, P2PProtocolInitializedEventArg _discoveryApp.AddNodeToDiscovery(session.Node); } - public void AddSupportedCapability(Capability capability) => _capabilities.Add(capability); - - public void RemoveSupportedCapability(Capability capability) - { - if (_capabilities.Remove(capability)) - { - if (_logger.IsTrace) _logger.Trace($"Removed supported capability: {capability}"); - } - } - public int GetHighestProtocolVersion(string protocol) { int highestVersion = 0; - foreach (Capability capability in _capabilities) + foreach (Capability capability in GetAdvertisedCapabilities()) { if (capability.ProtocolCode == protocol) { @@ -361,5 +356,43 @@ public int GetHighestProtocolVersion(string protocol) return highestVersion; } + + private void InvalidateCapabilities() + { + lock (_capabilitiesLock) + { + _cachedCapabilities = null; + } + } + + /// + /// Returns the capabilities to advertise, computed by running the registered + /// s (including ) over an empty + /// set. The result is cached and only recomputed when a resolver signals a change via + /// , keeping the per-session path allocation-free. + /// + private Capability[] GetAdvertisedCapabilities() + { + Capability[]? cached = Volatile.Read(ref _cachedCapabilities); + if (cached is not null) return cached; + + lock (_capabilitiesLock) + { + if (_cachedCapabilities is null) + { + HashSet capabilities = []; + foreach (IP2PCapabilityResolver resolver in _capabilityResolvers) + { + resolver.Resolve(capabilities); + } + + Capability[] resolved = capabilities.ToArray(); + if (_logger.IsDebug) _logger.Debug($"Resolved advertised P2P capabilities: {string.Join(", ", resolved.Select(static c => $"{c.ProtocolCode}/{c.Version}"))}"); + Volatile.Write(ref _cachedCapabilities, resolved); + } + + return _cachedCapabilities!; + } + } } } diff --git a/src/Nethermind/Nethermind.Network/SnapCapabilitySwitcher.cs b/src/Nethermind/Nethermind.Network/SnapCapabilitySwitcher.cs deleted file mode 100644 index 6ef7cdb21cd9..000000000000 --- a/src/Nethermind/Nethermind.Network/SnapCapabilitySwitcher.cs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only -// - -using System; -using Nethermind.Logging; -using Nethermind.Network.Contract.P2P; -using Nethermind.Stats.Model; -using Nethermind.Synchronization.ParallelSync; - -namespace Nethermind.Network; - -// Temporary class used for removing snap capability after SnapSync finish. -// Will be removed after implementing missing functionality - serving data via snap protocol. -public class SnapCapabilitySwitcher : IDisposable -{ - private static readonly Capability SnapCapability = new(Protocol.Snap, 1); - - private readonly IProtocolsManager _protocolsManager; - private readonly ISyncModeSelector _syncModeSelector; - private readonly ILogger _logger; - private volatile bool _isSubscribed; - - public SnapCapabilitySwitcher(IProtocolsManager? protocolsManager, ISyncModeSelector? syncModeSelector, ILogManager? logManager) - { - ArgumentNullException.ThrowIfNull(protocolsManager); - ArgumentNullException.ThrowIfNull(syncModeSelector); - ArgumentNullException.ThrowIfNull(logManager); - - _protocolsManager = protocolsManager; - _syncModeSelector = syncModeSelector; - _logger = logManager.GetClassLogger(); - } - - /// - /// Add Snap capability if SnapSync is not finished and remove after finished. - /// - public void EnableSnapCapabilityUntilSynced() - { - if (!_isSubscribed) - { - _protocolsManager.AddSupportedCapability(SnapCapability); - _syncModeSelector.Changed += OnSyncModeChanged; - _isSubscribed = true; - } - - if (_logger.IsDebug) _logger.Debug("Enabled snap capability"); - } - - private void OnSyncModeChanged(object? sender, SyncModeChangedEventArgs syncMode) - { - if ((syncMode.Current & SyncMode.Full) != 0) - { - DisableSnapCapability(); - if (_logger.IsInfo) _logger.Info("State sync finished. Disabled snap capability."); - } - } - - public void Dispose() => DisableSnapCapability(); - - private void DisableSnapCapability() - { - if (_isSubscribed) - { - _syncModeSelector.Changed -= OnSyncModeChanged; - _protocolsManager.RemoveSupportedCapability(SnapCapability); - _isSubscribed = false; - } - } -} diff --git a/src/Nethermind/Nethermind.Network/SnapP2PCapabilityResolver.cs b/src/Nethermind/Nethermind.Network/SnapP2PCapabilityResolver.cs new file mode 100644 index 000000000000..d0cdcb47eb0f --- /dev/null +++ b/src/Nethermind/Nethermind.Network/SnapP2PCapabilityResolver.cs @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Blockchain.Synchronization; +using Nethermind.Logging; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; +using Nethermind.Synchronization.ParallelSync; + +namespace Nethermind.Network; + +/// +/// Advertises snap/1 while the node serves snap data or still needs to snap-sync its own state. +/// +/// +/// Replaces the former SnapCapabilitySwitcher: instead of adding the capability on start and removing it +/// when state sync reaches , the contribution is recomputed per session, so a session +/// opened after sync completes simply no longer advertises snap. +/// +public class SnapP2PCapabilityResolver : IP2PCapabilityResolver, IDisposable +{ + private static readonly Capability SnapCapability = new(Protocol.Snap, 1); + + private readonly ISyncConfig _syncConfig; + private readonly ISyncModeSelector _syncModeSelector; + private readonly ILogger _logger; + + public event Action? Changed; + + public SnapP2PCapabilityResolver(ISyncConfig syncConfig, ISyncModeSelector syncModeSelector, ILogManager logManager) + { + _syncConfig = syncConfig; + _syncModeSelector = syncModeSelector; + _logger = logManager.GetClassLogger(); + _syncModeSelector.Changed += OnSyncModeChanged; + } + + public void Resolve(ISet capabilities) + { + bool serving = _syncConfig.SnapServingEnabled == true; + bool syncingState = _syncConfig.SnapSync && (_syncModeSelector.Current & SyncMode.Full) == 0; + if (serving || syncingState) + { + capabilities.Add(SnapCapability); + } + } + + private void OnSyncModeChanged(object? sender, SyncModeChangedEventArgs e) + { + // snap/1's contribution only tracks the sync mode while we snap-sync our own state and are not also + // serving snap; in every other configuration it is constant, so the rebuild is pointless. + if (_syncConfig.SnapServingEnabled == true || !_syncConfig.SnapSync) return; + + bool wasSyncing = (e.Previous & SyncMode.Full) == 0; + bool isSyncing = (e.Current & SyncMode.Full) == 0; + if (wasSyncing == isSyncing) return; + + if (_logger.IsDebug) _logger.Debug($"State sync {(isSyncing ? "in progress" : "finished")}; snap/1 advertisement {(isSyncing ? "enabled" : "disabled")}"); + Changed?.Invoke(); + } + + public void Dispose() => _syncModeSelector.Changed -= OnSyncModeChanged; +} diff --git a/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs b/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs index 48db2f11f070..81c49d884664 100644 --- a/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs +++ b/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs @@ -176,7 +176,6 @@ public async Task Initialize_network_registers_plugin_capabilities_before_starti await initializeNetwork.RunInitPeer(); Assert.That(callOrder, Is.EqualTo(new[] { "plugin", "rlpx" })); - protocolsManager.DidNotReceive().RemoveSupportedCapability(new Capability(Protocol.NodeData, 1)); } private IEnumerable<(Type MessageType, Type SerializerType)> FindSerializersInAssembly(Assembly assembly) @@ -250,7 +249,6 @@ private sealed class TestInitializeNetwork( Substitute.For(), plugins, new Lazy(() => protocolsManager), - new Lazy(() => null!), networkConfig, syncConfig, Substitute.For(), diff --git a/src/Nethermind/Nethermind.Synchronization.Test/E2ESyncTests.cs b/src/Nethermind/Nethermind.Synchronization.Test/E2ESyncTests.cs index b2dfaf9f1a95..7693bfde8c42 100644 --- a/src/Nethermind/Nethermind.Synchronization.Test/E2ESyncTests.cs +++ b/src/Nethermind/Nethermind.Synchronization.Test/E2ESyncTests.cs @@ -39,6 +39,7 @@ using Nethermind.Merge.Plugin.BlockProduction; using Nethermind.Merge.Plugin.Handlers; using Nethermind.Merge.Plugin.Synchronization; +using Nethermind.Core.Container; using Nethermind.Network; using Nethermind.Network.Config; using Nethermind.Network.Contract.P2P; @@ -306,6 +307,7 @@ private async Task CreateNode( .AddModule(new TestMergeModule(configProvider.GetConfig())) .AddSingleton(timestamper) // Used by test code .AddDecorator() + .AddLast() ; } else @@ -320,20 +322,19 @@ private async Task CreateNode( IContainer container = builder.Build(); - if (isPostMerge) - { - EnablePostMergeEthCapabilities(container.Resolve()); - } - return container; } - private static void EnablePostMergeEthCapabilities(IProtocolsManager protocolsManager) + private sealed class PostMergeCapabilitiesResolver : IP2PCapabilityResolver { - ArgumentNullException.ThrowIfNull(protocolsManager); - protocolsManager.AddSupportedCapability(new Capability(Protocol.Eth, EthVersions.Eth69)); - protocolsManager.AddSupportedCapability(new Capability(Protocol.Eth, EthVersions.Eth70)); - protocolsManager.AddSupportedCapability(new Capability(Protocol.Eth, EthVersions.Eth71)); + public event Action? Changed { add { } remove { } } + + public void Resolve(ISet capabilities) + { + capabilities.Add(new Capability(Protocol.Eth, EthVersions.Eth69)); + capabilities.Add(new Capability(Protocol.Eth, EthVersions.Eth70)); + capabilities.Add(new Capability(Protocol.Eth, EthVersions.Eth71)); + } } private static void EnableBlockAccessListsFromGenesis(ChainSpec spec) diff --git a/src/Nethermind/Nethermind.Xdc.Test/XdcP2PCapabilityResolverTests.cs b/src/Nethermind/Nethermind.Xdc.Test/XdcP2PCapabilityResolverTests.cs new file mode 100644 index 000000000000..d9dc5d2f5ba9 --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc.Test/XdcP2PCapabilityResolverTests.cs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using Autofac; +using Nethermind.Core.Container; +using Nethermind.Network; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; +using NUnit.Framework; + +namespace Nethermind.Xdc.Test; + +[Parallelizable(ParallelScope.All)] +public class XdcP2PCapabilityResolverTests +{ + [Test] + public void Resolve_advertises_xdc_capabilities() + { + XdcP2PCapabilityResolver resolver = new(); + + HashSet capabilities = []; + resolver.Resolve(capabilities); + + Assert.That(capabilities, Is.EquivalentTo(new[] + { + new Capability(Protocol.Eth, 62), + new Capability(Protocol.Eth, 63), + new Capability(Protocol.Eth, 100), + })); + } + + [Test] + public void Di_composition_drops_default_eth68_resolver() + { + // Mirrors NetworkModule (AddFirst default) followed by XdcModule (AddLast xdc, then deregister default). + using IContainer container = new ContainerBuilder() + .AddFirst() + .AddLast() + .RemoveOrderedComponents() + .Build(); + + IP2PCapabilityResolver[] resolvers = container.Resolve(); + Assert.That(resolvers, Has.None.TypeOf()); + + HashSet capabilities = []; + foreach (IP2PCapabilityResolver resolver in resolvers) resolver.Resolve(capabilities); + + // eth/68 (the default's contribution) is gone; only XDC's versions remain. + Assert.That(capabilities, Is.EquivalentTo(new[] + { + new Capability(Protocol.Eth, 62), + new Capability(Protocol.Eth, 63), + new Capability(Protocol.Eth, 100), + })); + } +} diff --git a/src/Nethermind/Nethermind.Xdc/XdcModule.cs b/src/Nethermind/Nethermind.Xdc/XdcModule.cs index d78bc24e12e1..0856411fdbe5 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcModule.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcModule.cs @@ -131,6 +131,8 @@ protected override void Load(ContainerBuilder builder) .AddMessageSerializer() .AddLast() + .AddLast() + .RemoveOrderedComponents() .AddSingleton() // block processing diff --git a/src/Nethermind/Nethermind.Xdc/XdcP2PCapabilityResolver.cs b/src/Nethermind/Nethermind.Xdc/XdcP2PCapabilityResolver.cs new file mode 100644 index 000000000000..42b47a1affcd --- /dev/null +++ b/src/Nethermind/Nethermind.Xdc/XdcP2PCapabilityResolver.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using Nethermind.Network; +using Nethermind.Network.Contract.P2P; +using Nethermind.Stats.Model; + +namespace Nethermind.Xdc; + +/// +/// XDC advertises eth/62, eth/63 and eth/100. The default eth/68 resolver is dropped at registration +/// (see XdcModule), so this resolver only contributes the XDC-specific versions. +/// +public class XdcP2PCapabilityResolver : IP2PCapabilityResolver +{ + // XDC's capability set is static, so the cache never needs invalidating. + public event Action? Changed { add { } remove { } } + + public void Resolve(ISet capabilities) + { + capabilities.Add(new Capability(Protocol.Eth, 62)); + capabilities.Add(new Capability(Protocol.Eth, 63)); + capabilities.Add(new Capability(Protocol.Eth, 100)); + } +} diff --git a/src/Nethermind/Nethermind.Xdc/XdcPlugin.cs b/src/Nethermind/Nethermind.Xdc/XdcPlugin.cs index 596f3b4d5b87..eeb871624c2a 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcPlugin.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcPlugin.cs @@ -4,7 +4,6 @@ using Autofac.Core; using Nethermind.Api; using Nethermind.Api.Extensions; -using Nethermind.Network.Contract.P2P; using Nethermind.Specs.ChainSpecStyle; using System.Threading.Tasks; @@ -26,15 +25,4 @@ public Task Init(INethermindApi nethermindApi) _nethermindApi = nethermindApi; return Task.CompletedTask; } - - public Task InitNetworkProtocol() - { - // Remove default ETH 68 capability (XDC uses 62-63 and 100) - _nethermindApi.ProtocolsManager!.RemoveSupportedCapability(new(Protocol.Eth, 68)); - - _nethermindApi.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 62)); - _nethermindApi.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 63)); - _nethermindApi.ProtocolsManager!.AddSupportedCapability(new(Protocol.Eth, 100)); - return Task.CompletedTask; - } } diff --git a/src/Nethermind/Nethermind.Xdc/XdcSubnetPlugin.cs b/src/Nethermind/Nethermind.Xdc/XdcSubnetPlugin.cs index 5c35a6e5179f..3e006bf17ac4 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcSubnetPlugin.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcSubnetPlugin.cs @@ -22,6 +22,4 @@ public class XdcSubnetPlugin(ChainSpec chainSpec) : IConsensusPlugin public IModule Module => new XdcSubnetModule(); public Task Init(INethermindApi nethermindApi) => _xdcPlugin.Init(nethermindApi); - - public Task InitNetworkProtocol() => _xdcPlugin.InitNetworkProtocol(); }