diff --git a/.github/workflows/rpc-comparison.yml b/.github/workflows/rpc-comparison.yml index 933fa14ddaf2..b15b344c8afe 100644 --- a/.github/workflows/rpc-comparison.yml +++ b/.github/workflows/rpc-comparison.yml @@ -142,7 +142,7 @@ jobs: network: ${{ inputs.network }} convert_to_paprika: "${{ inputs.convert_to_paprika }}" - # Spawn a pruned Geth node via the smoke-tests framework + # Spawn a pruned Geth node via the smoke-tests framework create_geth_node: name: Spawn pruned Geth node uses: ./.github/workflows/run-a-geth-node.yml diff --git a/Directory.Packages.props b/Directory.Packages.props index c6c59956c16d..1af9891cf9d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,6 @@ - diff --git a/src/Nethermind/Nethermind.Config/Nethermind.Config.csproj b/src/Nethermind/Nethermind.Config/Nethermind.Config.csproj index f0797f7a8cb5..44c4c6b1b596 100644 --- a/src/Nethermind/Nethermind.Config/Nethermind.Config.csproj +++ b/src/Nethermind/Nethermind.Config/Nethermind.Config.csproj @@ -7,11 +7,11 @@ - + diff --git a/src/Nethermind/Nethermind.Config/NetworkNode.cs b/src/Nethermind/Nethermind.Config/NetworkNode.cs index 3c95db5d4cf0..20aa7f559a59 100644 --- a/src/Nethermind/Nethermind.Config/NetworkNode.cs +++ b/src/Nethermind/Nethermind.Config/NetworkNode.cs @@ -2,12 +2,9 @@ // SPDX-License-Identifier: LGPL-3.0-only #nullable enable -using Lantern.Discv5.Enr; -using Lantern.Discv5.Enr.Entries; -using Lantern.Discv5.Enr.Identity; -using Lantern.Discv5.Enr.Identity.V4; using Nethermind.Core.Crypto; using Nethermind.Logging; +using Nethermind.Network.Enr; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -20,11 +17,8 @@ namespace Nethermind.Config; /// public class NetworkNode { - private static readonly EnrFactory _enrFactory = new(new EnrEntryRegistry()); - private static readonly IIdentityVerifier identityVerifier = new IdentityVerifierV4(); - private readonly Enode? _enode; - private readonly Enr? _enr; + private readonly NodeRecord? _enr; [MemberNotNullWhen(true, nameof(Enode))] [MemberNotNullWhen(false, nameof(Enr))] @@ -42,7 +36,7 @@ public NetworkNode(string nodeString) } else { - _enr = _enrFactory.CreateFromString(nodeString, identityVerifier); + _enr = NodeRecord.FromEnrString(nodeString); } } @@ -82,7 +76,7 @@ public static NetworkNode[] ParseNodes(string[]? nodeRecords, ILogger logger) return [.. nodes]; } - public override string ToString() => IsEnode ? Enode.ToString() : Enr.ToString(); + public override string ToString() => IsEnode ? Enode.ToString() : Enr.EnrString; public NetworkNode(PublicKey publicKey, string ip, int port, long reputation = 0) : this(new Enode(publicKey, IPAddress.Parse(ip), port)) => Reputation = reputation; @@ -91,11 +85,20 @@ public NetworkNode(PublicKey publicKey, string ip, int port, long reputation = 0 public Enode? Enode => _enode; - public Enr? Enr => _enr; + public NodeRecord? Enr => _enr; - public PublicKey NodeId => IsEnode ? Enode.PublicKey : new PublicKey(Enr.GetEntry(EnrEntryKey.Secp256K1).Value); - public string Host => IsEnode ? Enode.HostIp.ToString() : Enr.GetEntry(EnrEntryKey.Ip).Value.ToString(); - public IPAddress HostIp => IsEnode ? Enode.HostIp : Enr.GetEntry(EnrEntryKey.Ip).Value; - public int Port => IsEnode ? Enode.Port : Enr.GetEntry(EnrEntryKey.Tcp).Value; + public PublicKey NodeId => IsEnode ? Enode.PublicKey : GetEnrPublicKey(); + public string Host => IsEnode ? Enode.HostIp.ToString() : HostIp.ToString(); + public IPAddress HostIp => IsEnode ? Enode.HostIp : Enr!.DiscoveryIp ?? IPAddress.None; + public int Port => IsEnode ? Enode.Port : Enr!.DiscoveryPort ?? 0; + public int DiscoveryPort => IsEnode ? Enode.DiscoveryPort : Enr!.DiscoveryPort ?? 0; public long Reputation { get; set; } + + private PublicKey GetEnrPublicKey() + { + CompressedPublicKey publicKey = Enr!.GetObj(EnrContentKey.SecP256k1) + ?? throw new InvalidOperationException("ENR is missing secp256k1 public key."); + + return publicKey.Decompress(); + } } diff --git a/src/Nethermind/Nethermind.Core.Test/Caching/LruCacheTests.cs b/src/Nethermind/Nethermind.Core.Test/Caching/LruCacheTests.cs index 2c5850808157..3bb04e45acaa 100755 --- a/src/Nethermind/Nethermind.Core.Test/Caching/LruCacheTests.cs +++ b/src/Nethermind/Nethermind.Core.Test/Caching/LruCacheTests.cs @@ -235,6 +235,82 @@ public void Can_delete() Assert.That(cache.Delete(_addresses[0]), Is.False); } + [Test] + public void Can_remove_and_return_value() + { + LruCache cache = new(Capacity, "test"); + cache.Set(_addresses[0], _accounts[0]); + + Assert.That(cache.TryRemove(_addresses[0], out Account? removed), Is.True); + Assert.That(removed, Is.EqualTo(_accounts[0])); + Assert.That(cache.TryRemove(_addresses[0], out removed), Is.False); + Assert.That(removed, Is.Null); + } + + [Test] + public void Evict_is_called_when_capacity_replaces_oldest() + { + int evicted = 0; + LruCache cache = new TestEvictingLruCache(2, "test", value => evicted = value); + + cache.Set(1, 10); + cache.Set(2, 20); + cache.Set(3, 30); + + Assert.That(evicted, Is.EqualTo(10)); + } + + [Test] + public void Evict_is_called_when_existing_value_is_replaced() + { + int evicted = 0; + LruCache cache = new TestEvictingLruCache(2, "test", value => evicted = value); + + cache.Set(1, 10); + cache.Set(1, 11); + + Assert.That(evicted, Is.EqualTo(10)); + Assert.That(cache.Get(1), Is.EqualTo(11)); + } + + [Test] + public void TryRemove_returns_value_without_calling_evict() + { + int evicted = 0; + LruCache cache = new TestEvictingLruCache(2, "test", value => evicted = value); + cache.Set(1, 10); + + Assert.That(cache.TryRemove(1, out int removed), Is.True); + + Assert.That(removed, Is.EqualTo(10)); + Assert.That(evicted, Is.Zero); + } + + [Test] + public void Disposing_cache_disposes_evicted_values() + { + DisposingLruCache cache = new(1, "test"); + DisposableValue evicted = new(); + + cache.Set(1, evicted); + cache.Set(2, new DisposableValue()); + + Assert.That(evicted.IsDisposed, Is.True); + } + + [Test] + public void Disposing_cache_try_remove_transfers_ownership() + { + DisposingLruCache cache = new(1, "test"); + DisposableValue removed = new(); + cache.Set(1, removed); + + Assert.That(cache.TryRemove(1, out DisposableValue? actual), Is.True); + + Assert.That(actual, Is.SameAs(removed)); + Assert.That(removed.IsDisposed, Is.False); + } + [Test] public void Clear_should_free_all_capacity() { @@ -261,6 +337,29 @@ public void Clear_should_free_all_capacity() } } + [TestCase(EvictionOperation.Delete, false)] + [TestCase(EvictionOperation.ReplaceExisting, true)] + [TestCase(EvictionOperation.ReplaceOldest, false)] + [TestCase(EvictionOperation.Clear, false)] + public async Task Evict_is_invoked_outside_lock(EvictionOperation operation, bool expectedContainsResult) + { + LruCache cache = null!; + TaskCompletionSource evictResult = new(TaskCreationOptions.RunContinuationsAsynchronously); + cache = new TestEvictingLruCache(2, "test", _ => evictResult.SetResult(cache.Contains(1))); + cache.Set(1, 10); + if (operation == EvictionOperation.ReplaceOldest) + { + cache.Set(2, 20); + } + + Task operationTask = Task.Run(() => RunEvictionOperation(cache, operation)); + Task completedTask = await Task.WhenAny(operationTask, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.That(completedTask, Is.SameAs(operationTask)); + await operationTask; + Assert.That(await evictResult.Task.WaitAsync(TimeSpan.FromSeconds(5)), Is.EqualTo(expectedContainsResult)); + } + [Test] public void Delete_keeps_internal_structure() { @@ -301,5 +400,50 @@ public void Wrong_capacity_number_at_constructor() }); } + + private static void RunEvictionOperation(LruCache cache, EvictionOperation operation) + { + switch (operation) + { + case EvictionOperation.Delete: + cache.Delete(1); + return; + case EvictionOperation.ReplaceExisting: + cache.Set(1, 11); + return; + case EvictionOperation.ReplaceOldest: + cache.Set(3, 30); + return; + case EvictionOperation.Clear: + cache.Clear(); + return; + default: + throw new ArgumentOutOfRangeException(nameof(operation), operation, null); + } + } + + public enum EvictionOperation + { + Delete, + ReplaceExisting, + ReplaceOldest, + Clear + } + + private sealed class TestEvictingLruCache( + int maxCapacity, + string name, + Action evict) : LruCache(maxCapacity, name) + where TKey : notnull + { + protected override void Evict(TValue value) => evict(value); + } + + private sealed class DisposableValue : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; + } } } diff --git a/src/Nethermind/Nethermind.Core.Test/Modules/InsecureProtectedPrivateKey.cs b/src/Nethermind/Nethermind.Core.Test/Modules/InsecureProtectedPrivateKey.cs index 879fbd4019cb..ab55871b4a0d 100644 --- a/src/Nethermind/Nethermind.Core.Test/Modules/InsecureProtectedPrivateKey.cs +++ b/src/Nethermind/Nethermind.Core.Test/Modules/InsecureProtectedPrivateKey.cs @@ -10,5 +10,5 @@ public class InsecureProtectedPrivateKey(PrivateKey privateKey) : IProtectedPriv { public PublicKey PublicKey => privateKey.PublicKey; public CompressedPublicKey CompressedPublicKey => privateKey.CompressedPublicKey; - public PrivateKey Unprotect() => privateKey; + public PrivateKey Unprotect() => new(privateKey.KeyBytes); } diff --git a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindRunner.cs b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindRunner.cs index 6f74bb97939e..b2f21482e8f6 100644 --- a/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindRunner.cs +++ b/src/Nethermind/Nethermind.Core.Test/Modules/PseudoNethermindRunner.cs @@ -83,7 +83,7 @@ public async Task StartDiscovery(CancellationToken cancellationToken) await ctx.Resolve().InitAsync(); _discoveryApp = ctx.Resolve(); - _ = _discoveryApp.StartAsync(); // Bootstrap is not blocking by default + await _discoveryApp.StartAsync(); _peerPool = ctx.Resolve(); _peerPool.Start(); diff --git a/src/Nethermind/Nethermind.Core.Test/Modules/TestEnvironmentModule.cs b/src/Nethermind/Nethermind.Core.Test/Modules/TestEnvironmentModule.cs index 5eedb94ef173..61c007e62c6e 100644 --- a/src/Nethermind/Nethermind.Core.Test/Modules/TestEnvironmentModule.cs +++ b/src/Nethermind/Nethermind.Core.Test/Modules/TestEnvironmentModule.cs @@ -44,7 +44,6 @@ protected override void Load(ContainerBuilder builder) // These two don't use the DB provider .AddKeyedSingleton(DbNames.PeersDb, (_) => new MemDb()) .AddKeyedSingleton(DbNames.DiscoveryNodes, (_) => new MemDb()) - .AddKeyedSingleton(DbNames.DiscoveryV5Nodes, (_) => new MemDb()) .AddSingleton(networkConfig => new LocalChannelFactory(networkGroup ?? nameof(TestEnvironmentModule), networkConfig)) .AddSingleton(NodeFilter.AcceptAll) // Disable inbound rate limiting for in-memory channels diff --git a/src/Nethermind/Nethermind.Core/Caching/DisposingLruCache.cs b/src/Nethermind/Nethermind.Core/Caching/DisposingLruCache.cs new file mode 100644 index 000000000000..9cc55123f2bb --- /dev/null +++ b/src/Nethermind/Nethermind.Core/Caching/DisposingLruCache.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; + +namespace Nethermind.Core.Caching; + +public sealed class DisposingLruCache : LruCache + where TKey : notnull + where TValue : IDisposable +{ + public DisposingLruCache(int maxCapacity, int startCapacity, string name) + : base(maxCapacity, startCapacity, name) + { + } + + public DisposingLruCache(int maxCapacity, string name) + : base(maxCapacity, name) + { + } + + protected override void Evict(TValue value) => value.Dispose(); +} diff --git a/src/Nethermind/Nethermind.Core/Caching/LruCache.cs b/src/Nethermind/Nethermind.Core/Caching/LruCache.cs index 56f195b7551c..e205d56848a3 100644 --- a/src/Nethermind/Nethermind.Core/Caching/LruCache.cs +++ b/src/Nethermind/Nethermind.Core/Caching/LruCache.cs @@ -4,13 +4,12 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using Nethermind.Core.Extensions; using Nethermind.Core.Threading; namespace Nethermind.Core.Caching { - public sealed class LruCache : ICache where TKey : notnull + public class LruCache : ICache where TKey : notnull { private readonly int _maxCapacity; private readonly Dictionary> _cacheMap; @@ -36,10 +35,24 @@ public LruCache(int maxCapacity, string name) public void Clear() { - using McsLock.Disposable lockRelease = _lock.Acquire(); + TValue[]? evictedValues = null; + using (McsLock.Disposable lockRelease = _lock.Acquire()) + { + if (_cacheMap.Count != 0) + { + int i = 0; + evictedValues = new TValue[_cacheMap.Count]; + foreach (KeyValuePair> kvp in _cacheMap) + { + evictedValues[i++] = kvp.Value.Value.Value; + } + } + + _leastRecentlyUsed = null; + _cacheMap.Clear(); + } - _leastRecentlyUsed = null; - _cacheMap.Clear(); + NotifyEvictedValues(evictedValues); } public TValue Get(TKey key) @@ -83,84 +96,144 @@ public TValue SetOrGet(TKey key, TState state, Func? node)) - { - TValue value = node.Value.Value; - LinkedListNode.MoveToMostRecent(ref _leastRecentlyUsed, node); - return value; - } - - TValue newValue = valueFactory(key, state); - if (newValue is null) + TValue evictedValue = default!; + bool notifyEviction = false; + TValue result; + using (McsLock.Disposable lockRelease = _lock.Acquire()) { - return newValue; + if (_cacheMap.TryGetValue(key, out LinkedListNode? node)) + { + TValue value = node.Value.Value; + LinkedListNode.MoveToMostRecent(ref _leastRecentlyUsed, node); + return value; + } + + TValue newValue = valueFactory(key, state); + if (newValue is null) + { + return newValue; + } + + if (_cacheMap.Count >= _maxCapacity) + { + evictedValue = Replace(key, newValue); + notifyEviction = true; + } + else + { + LinkedListNode newNode = new(new(key, newValue)); + LinkedListNode.AddMostRecent(ref _leastRecentlyUsed, newNode); + _cacheMap.Add(key, newNode); + } + + result = newValue; } - if (_cacheMap.Count >= _maxCapacity) - { - Replace(key, newValue); - } - else + if (notifyEviction) { - LinkedListNode newNode = new(new(key, newValue)); - LinkedListNode.AddMostRecent(ref _leastRecentlyUsed, newNode); - _cacheMap.Add(key, newNode); + NotifyEvicted(evictedValue); } - return newValue; + return result; } public bool Set(TKey key, TValue val) { - using McsLock.Disposable lockRelease = _lock.Acquire(); - - if (val is null) + TValue evictedValue = default!; + bool notifyEviction = false; + bool added; + using (McsLock.Disposable lockRelease = _lock.Acquire()) { - return DeleteNoLock(key); + if (val is null) + { + added = DeleteNoLock(key, out evictedValue); + notifyEviction = added; + } + else if (_cacheMap.TryGetValue(key, out LinkedListNode? node)) + { + evictedValue = node.Value.Value; + notifyEviction = true; + node.Value.Value = val; + LinkedListNode.MoveToMostRecent(ref _leastRecentlyUsed, node); + added = false; + } + else if (_cacheMap.Count >= _maxCapacity) + { + evictedValue = Replace(key, val); + notifyEviction = true; + added = true; + } + else + { + LinkedListNode newNode = new(new(key, val)); + LinkedListNode.AddMostRecent(ref _leastRecentlyUsed, newNode); + _cacheMap.Add(key, newNode); + added = true; + } } - if (_cacheMap.TryGetValue(key, out LinkedListNode? node)) + if (notifyEviction) { - node.Value.Value = val; - LinkedListNode.MoveToMostRecent(ref _leastRecentlyUsed, node); - return false; + NotifyEvicted(evictedValue); } - if (_cacheMap.Count >= _maxCapacity) + return added; + } + + public bool Delete(TKey key) + { + TValue evictedValue = default!; + bool removed; + using (McsLock.Disposable lockRelease = _lock.Acquire()) { - Replace(key, val); + removed = DeleteNoLock(key, out evictedValue); } - else + + if (removed) { - LinkedListNode newNode = new(new(key, val)); - LinkedListNode.AddMostRecent(ref _leastRecentlyUsed, newNode); - _cacheMap.Add(key, newNode); + NotifyEvicted(evictedValue); } - return true; + return removed; } - public bool Delete(TKey key) + /// + /// Deletes a cached value and returns it when the key is present. + /// + public bool TryRemove(TKey key, [MaybeNullWhen(false)] out TValue value) { using McsLock.Disposable lockRelease = _lock.Acquire(); - return DeleteNoLock(key); + if (_cacheMap.TryGetValue(key, out LinkedListNode? node)) + { + value = node.Value.Value; + RemoveNoLock(key, node); + return true; + } + + value = default; + return false; } - private bool DeleteNoLock(TKey key) + private bool DeleteNoLock(TKey key, out TValue evictedValue) { if (_cacheMap.TryGetValue(key, out LinkedListNode? node)) { - LinkedListNode.Remove(ref _leastRecentlyUsed, node); - _cacheMap.Remove(key); + evictedValue = node.Value.Value; + RemoveNoLock(key, node); return true; } + evictedValue = default!; return false; } + private void RemoveNoLock(TKey key, LinkedListNode node) + { + LinkedListNode.Remove(ref _leastRecentlyUsed, node); + _cacheMap.Remove(key); + } + public bool Contains(TKey key) { using McsLock.Disposable lockRelease = _lock.Acquire(); @@ -182,9 +255,10 @@ public KeyValuePair[] ToArray() return array; } - [MethodImpl(MethodImplOptions.Synchronized)] public TValue[] GetValues() { + using McsLock.Disposable lockRelease = _lock.Acquire(); + int i = 0; TValue[] array = new TValue[_cacheMap.Count]; foreach (KeyValuePair> kvp in _cacheMap) @@ -197,7 +271,11 @@ public TValue[] GetValues() public int Count => _cacheMap.Count; - private void Replace(TKey key, TValue value) + protected virtual void Evict(TValue value) + { + } + + private TValue Replace(TKey key, TValue value) { LinkedListNode? node = _leastRecentlyUsed; if (node is null) @@ -205,17 +283,40 @@ private void Replace(TKey key, TValue value) ThrowInvalidOperationException(); } - _cacheMap.Remove(node!.Value.Key); + TValue evictedValue = node!.Value.Value; + _cacheMap.Remove(node.Value.Key); node.Value = new(key, value); LinkedListNode.MoveToMostRecent(ref _leastRecentlyUsed, node); _cacheMap.Add(key, node); + return evictedValue; [DoesNotReturn] static void ThrowInvalidOperationException() => throw new InvalidOperationException( $"{nameof(LruCache)} called {nameof(Replace)} when empty."); } + private void NotifyEvictedValues(TValue[]? evictedValues) + { + if (evictedValues is null) + { + return; + } + + for (int i = 0; i < evictedValues.Length; i++) + { + NotifyEvicted(evictedValues[i]); + } + } + + private void NotifyEvicted(TValue value) + { + if (value is not null) + { + Evict(value); + } + } + private struct LruCacheItem(TKey k, TValue v) { public readonly TKey Key = k; diff --git a/src/Nethermind/Nethermind.Crypto/PrivateKey.cs b/src/Nethermind/Nethermind.Crypto/PrivateKey.cs index 8baf43de123b..ae7e7a841e3f 100644 --- a/src/Nethermind/Nethermind.Crypto/PrivateKey.cs +++ b/src/Nethermind/Nethermind.Crypto/PrivateKey.cs @@ -52,6 +52,22 @@ public PrivateKey(byte[] keyBytes) public Address Address => PublicKey.Address; + /// + /// Computes the compressed secp256k1 ECDH shared EC point for this private key and a remote public key. + /// + /// The remote public key. + /// The 33-byte compressed ECDH shared EC point. + public byte[] GetCompressedSharedPoint(PublicKey publicKey) => + SecP256k1Ecdh.GetCompressedSharedPoint(publicKey.PrefixedBytes, KeyBytes); + + /// + /// Computes the compressed secp256k1 ECDH shared EC point for this private key and a remote compressed public key. + /// + /// The remote compressed public key. + /// The 33-byte compressed ECDH shared EC point. + public byte[] GetCompressedSharedPoint(CompressedPublicKey publicKey) => + SecP256k1Ecdh.GetCompressedSharedPoint(publicKey.Bytes, KeyBytes); + private bool Equals(PrivateKey other) => Bytes.AreEqual(KeyBytes, other.KeyBytes); public override bool Equals(object obj) diff --git a/src/Nethermind/Nethermind.Crypto/SecP256k1Ecdh.cs b/src/Nethermind/Nethermind.Crypto/SecP256k1Ecdh.cs new file mode 100644 index 000000000000..37ca297ce58e --- /dev/null +++ b/src/Nethermind/Nethermind.Crypto/SecP256k1Ecdh.cs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using Nethermind.Core.Crypto; + +namespace Nethermind.Crypto; + +// The public SecP256k1.Ecdh API returns the default ECDH secret, while discv5 needs the compressed shared EC point. +internal static unsafe class SecP256k1Ecdh +{ + private const uint ContextNone = 1; + private const int ParsedPublicKeyLength = 64; + private const int CoordinateLength = 32; + + private static readonly EcdhHashFunction CompressedPointHashFunction = WriteCompressedPoint; + private static readonly SecP256k1ContextHandle Context = new(); + + [SkipLocalsInit] + internal static byte[] GetCompressedSharedPoint(ReadOnlySpan publicKey, ReadOnlySpan privateKey) + { + if (Context.IsInvalid) + { + throw new InvalidOperationException("Failed to create secp256k1 context."); + } + + Span parsedPublicKey = stackalloc byte[ParsedPublicKeyLength]; + Span output = stackalloc byte[CompressedPublicKey.LengthInBytes]; + + fixed (byte* publicKeyPtr = publicKey) + fixed (byte* privateKeyPtr = privateKey) + fixed (byte* parsedPublicKeyPtr = parsedPublicKey) + fixed (byte* outputPtr = output) + { + if (secp256k1_ec_pubkey_parse(Context, parsedPublicKeyPtr, publicKeyPtr, (nuint)publicKey.Length) != 1) + { + throw new ArgumentException("Invalid secp256k1 public key.", nameof(publicKey)); + } + + if (secp256k1_ecdh(Context, outputPtr, parsedPublicKeyPtr, privateKeyPtr, CompressedPointHashFunction, IntPtr.Zero) != 1) + { + throw new ArgumentException("Invalid secp256k1 private key.", nameof(privateKey)); + } + } + + return output.ToArray(); + } + + // The unmanaged callback receives libsecp256k1-owned 32-byte coordinates for the shared point and writes + // the compressed EC point form required by the devp2p discv5 key agreement. + private static int WriteCompressedPoint(byte* output, byte* x32, byte* y32, IntPtr data) + { + output[0] = (byte)(2 | (y32[CoordinateLength - 1] & 1)); + new ReadOnlySpan(x32, CoordinateLength).CopyTo(new Span(output + 1, CoordinateLength)); + return 1; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate int EcdhHashFunction(byte* output, byte* x32, byte* y32, IntPtr data); + + [DllImport("secp256k1", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr secp256k1_context_create(uint flags); + + [DllImport("secp256k1", CallingConvention = CallingConvention.Cdecl)] + private static extern void secp256k1_context_destroy(IntPtr context); + + [DllImport("secp256k1", CallingConvention = CallingConvention.Cdecl)] + private static extern int secp256k1_ec_pubkey_parse( + SecP256k1ContextHandle context, + byte* publicKey, + byte* input, + nuint inputLength); + + [DllImport("secp256k1", CallingConvention = CallingConvention.Cdecl)] + private static extern int secp256k1_ecdh( + SecP256k1ContextHandle context, + byte* output, + byte* publicKey, + byte* privateKey, + EcdhHashFunction hashFunction, + IntPtr data); + + private sealed class SecP256k1ContextHandle : SafeHandle + { + public SecP256k1ContextHandle() + : base(IntPtr.Zero, ownsHandle: true) + => handle = secp256k1_context_create(ContextNone); + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + secp256k1_context_destroy(handle); + return true; + } + } +} diff --git a/src/Nethermind/Nethermind.Init/Modules/DiscoveryModule.cs b/src/Nethermind/Nethermind.Init/Modules/DiscoveryModule.cs index fbb5c369fdce..4b198c09152b 100644 --- a/src/Nethermind/Nethermind.Init/Modules/DiscoveryModule.cs +++ b/src/Nethermind/Nethermind.Init/Modules/DiscoveryModule.cs @@ -11,9 +11,10 @@ using Nethermind.Network; using Nethermind.Network.Config; using Nethermind.Network.Discovery; +using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.Discovery.Discv5; -using Nethermind.Network.Discovery.Messages; -using Nethermind.Network.Discovery.Serializers; +using Nethermind.Network.Discovery.Discv4.Messages; +using Nethermind.Network.Discovery.Discv4.Serializers; using Nethermind.Network.Dns; using Nethermind.Network.Enr; using Nethermind.Network.StaticNodes; @@ -25,6 +26,15 @@ public class DiscoveryModule(IInitConfig initConfig, INetworkConfig networkConfi { protected override void Load(ContainerBuilder builder) { + builder.Register(static context => new NodesLoaderOptions( + LoadBootnodesAsPeerCandidates: (context.Resolve().DiscoveryVersion & DiscoveryVersion.V4) != 0)) + .SingleInstance(); + + builder.RegisterType() + .AsSelf() + .WithAttributeFiltering() + .SingleInstance(); + builder // Enr discovery uses DNS to get some bootnodes. .AddSingleton((ethereumEcdsa, logManager) => @@ -43,8 +53,6 @@ protected override void Load(ContainerBuilder builder) .AddSingleton(logManager => new StaticNodesManager(initConfig.StaticNodesPath.GetApplicationResourcePath(initConfig.DataDir), logManager)) // This load from file. - .AddSingleton() - .AddSingleton((logManager) => new TrustedNodesManager(initConfig.TrustedNodesPath.GetApplicationResourcePath(initConfig.DataDir), logManager)) diff --git a/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs b/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs index 6d29dbb179c6..dc15b6575a40 100644 --- a/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs +++ b/src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs @@ -16,7 +16,7 @@ using Nethermind.Network; using Nethermind.Network.Config; using Nethermind.Network.Contract.P2P; -using Nethermind.Network.Discovery; +using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.Rlpx; using Nethermind.Stats.Model; using Nethermind.Synchronization; diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/BucketAddResult.cs b/src/Nethermind/Nethermind.Kademlia/BucketAddResult.cs similarity index 77% rename from src/Nethermind/Nethermind.Network.Discovery/Kademlia/BucketAddResult.cs rename to src/Nethermind/Nethermind.Kademlia/BucketAddResult.cs index b4108865dfa1..4c85db0dd461 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/BucketAddResult.cs +++ b/src/Nethermind/Nethermind.Kademlia/BucketAddResult.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Network.Discovery.Kademlia; +namespace Nethermind.Kademlia; public enum BucketAddResult { diff --git a/src/Nethermind/Nethermind.Kademlia/DoubleEndedLru.cs b/src/Nethermind/Nethermind.Kademlia/DoubleEndedLru.cs new file mode 100644 index 000000000000..58618ce0a847 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/DoubleEndedLru.cs @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Fixed-capacity LRU map with O(1) access to both the most and the least recently used entry. +/// +/// +/// Entries live in a preallocated array threaded into an intrusive doubly-linked list (head = most recently +/// used, tail = least recently used); removed slots are recycled through a free list, so steady-state +/// operations do not allocate. All members are thread-safe. +/// +public class DoubleEndedLru(int capacity) + where TKey : notnull + where TValue : notnull +{ + private const int None = -1; + + private struct Entry + { + public TKey Key; + public TValue Value; + public int Prev; + public int Next; + } + + private readonly Lock _lock = new(); + private readonly Entry[] _entries = new Entry[capacity]; + private readonly Dictionary _index = new(capacity); + private int _head = None; + private int _tail = None; + private int _freeList = None; + private int _used; + + public int Count + { + get + { + lock (_lock) + { + return _index.Count; + } + } + } + + public BucketAddResult AddOrRefresh(in TKey key, TValue value) => AddOrRefresh(in key, value, out _); + + /// The value previously stored under when the entry is refreshed; default otherwise. + public BucketAddResult AddOrRefresh(in TKey key, TValue value, out TValue? previous) + { + lock (_lock) + { + if (_index.TryGetValue(key, out int i)) + { + ref Entry entry = ref _entries[i]; + previous = entry.Value; + entry.Value = value; + MoveToHead(i); + return BucketAddResult.Refreshed; + } + + previous = default; + if (_index.Count >= _entries.Length) + { + return BucketAddResult.Full; + } + + int slot = TakeSlot(); + ref Entry added = ref _entries[slot]; + added.Key = key; + added.Value = value; + LinkAtHead(slot); + _index.Add(key, slot); + return BucketAddResult.Added; + } + } + + public bool TryPopHead(out TKey key, out TValue? value) + { + lock (_lock) + { + if (_head == None) + { + key = default!; + value = default; + return false; + } + + int head = _head; + key = _entries[head].Key; + value = _entries[head].Value; + _index.Remove(key); + Unlink(head); + ReleaseSlot(head); + return true; + } + } + + public bool TryGetLast(out TValue? last) + { + lock (_lock) + { + if (_tail == None) + { + last = default; + return false; + } + + last = _entries[_tail].Value; + return true; + } + } + + public bool Remove(TKey key) + { + lock (_lock) + { + if (!_index.Remove(key, out int i)) + { + return false; + } + + Unlink(i); + ReleaseSlot(i); + return true; + } + } + + public TValue[] GetAll() + { + lock (_lock) + { + TValue[] result = new TValue[_index.Count]; + int n = 0; + for (int i = _head; i != None; i = _entries[i].Next) + { + result[n++] = _entries[i].Value; + } + + return result; + } + } + + public (TKey Key, TValue Value)[] GetAllWithKey() + { + lock (_lock) + { + (TKey Key, TValue Value)[] result = new (TKey Key, TValue Value)[_index.Count]; + int n = 0; + for (int i = _head; i != None; i = _entries[i].Next) + { + result[n++] = (_entries[i].Key, _entries[i].Value); + } + + return result; + } + } + + internal int CopyAllWithKey((TKey Key, TValue Value)[] destination) + { + lock (_lock) + { + int n = 0; + for (int i = _head; i != None; i = _entries[i].Next) + { + destination[n++] = (_entries[i].Key, _entries[i].Value); + } + + return n; + } + } + + public bool Contains(in TKey key) + { + lock (_lock) + { + return _index.ContainsKey(key); + } + } + + public TValue? GetByKey(TKey key) + { + lock (_lock) + { + return _index.TryGetValue(key, out int i) ? _entries[i].Value : default; + } + } + + public void Clear() + { + lock (_lock) + { + _index.Clear(); + Array.Clear(_entries); + _head = None; + _tail = None; + _freeList = None; + _used = 0; + } + } + + private int TakeSlot() + { + if (_freeList == None) + { + return _used++; + } + + int slot = _freeList; + _freeList = _entries[slot].Next; + return slot; + } + + private void ReleaseSlot(int i) + { + ref Entry entry = ref _entries[i]; + // Drop the key/value references so released slots do not keep them alive. + entry.Key = default!; + entry.Value = default!; + entry.Next = _freeList; + _freeList = i; + } + + private void LinkAtHead(int i) + { + ref Entry entry = ref _entries[i]; + entry.Prev = None; + entry.Next = _head; + if (_head != None) + { + _entries[_head].Prev = i; + } + else + { + _tail = i; + } + + _head = i; + } + + private void MoveToHead(int i) + { + if (_head == i) + { + return; + } + + Unlink(i); + LinkAtHead(i); + } + + private void Unlink(int i) + { + ref Entry entry = ref _entries[i]; + if (entry.Prev == None) + { + _head = entry.Next; + } + else + { + _entries[entry.Prev].Next = entry.Next; + } + + if (entry.Next == None) + { + _tail = entry.Prev; + } + else + { + _entries[entry.Next].Prev = entry.Prev; + } + } +} diff --git a/src/Nethermind/Nethermind.Kademlia/FromKeyNodeHashProvider.cs b/src/Nethermind/Nethermind.Kademlia/FromKeyNodeHashProvider.cs new file mode 100644 index 000000000000..0e6557adfd30 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/FromKeyNodeHashProvider.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + + +namespace Nethermind.Kademlia; + +public class FromKeyNodeHashProvider(IKeyOperator keyOperator) : INodeHashProvider + where TKadKey : notnull +{ + public TKadKey GetHash(TNode node) => keyOperator.GetNodeHash(node); +} diff --git a/src/Nethermind/Nethermind.Kademlia/IKademlia.cs b/src/Nethermind/Nethermind.Kademlia/IKademlia.cs new file mode 100644 index 000000000000..5e58fc424609 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IKademlia.cs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Provides routing-table maintenance and iterative Kademlia lookup over caller-defined key and node types. +/// +/// The protocol-specific lookup key type. +/// The protocol-specific node/contact type. +public interface IKademlia +{ + /// + /// Adds a node to the routing table or refreshes its position when it is already present. + /// + /// Node to add or refresh. + void AddOrRefresh(TNode node); + + /// + /// Removes a node from the routing table. + /// + /// Node to remove. + void Remove(TNode node); + + /// + /// Runs periodic bootstrap and routing-table refresh until cancelled. + /// + /// Cancellation token that stops the maintenance loop. + Task Run(CancellationToken token); + + /// + /// Runs one bootstrap pass and refreshes stale non-empty buckets. + /// + /// Cancellation token for the bootstrap pass. + Task Bootstrap(CancellationToken token); + + /// + /// Looks up nodes closest to by traversing the network. + /// + /// Protocol-specific lookup key. + /// Cancellation token for the lookup. + /// Optional result size. Defaults to . + Task LookupNodesClosest(TKey key, CancellationToken token, int? k = null); + + /// + /// Looks up nodes near and streams newly discovered candidates as soon as they are seen. + /// + /// Protocol-specific lookup key. + /// Cancellation token for the lookup. + /// Optional maximum number of candidates to emit. Defaults to . + IAsyncEnumerable LookupNodes(TKey key, CancellationToken token, int? maxResults = null); + + /// + /// Returns the closest routing-table entries to without traversing the network. + /// + /// Protocol-specific lookup key. + /// Optional node to exclude from the result. + /// Whether to exclude the local node from the result. + /// The returned array is not sorted. + TNode[] GetKNeighbour(TKey target, TNode? excluding = default, bool excludeSelf = false); + + /// + /// Return all table entries whose hash is at the requested log distance from the local node. + /// + /// The XOR log distance from the local node. + TNode[] GetAllAtDistance(int distance); + + /// + /// Raised when a node is added to the routing table. + /// + event EventHandler OnNodeAdded; + + /// + /// Raised when a node is removed from the routing table. + /// + event EventHandler OnNodeRemoved; + + /// + /// Iterates all nodes currently in the routing table without ordering guarantees. + /// + IEnumerable IterateNodes(); +} diff --git a/src/Nethermind/Nethermind.Kademlia/IKademliaDiscovery.cs b/src/Nethermind/Nethermind.Kademlia/IKademliaDiscovery.cs new file mode 100644 index 000000000000..d15bdede0d09 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IKademliaDiscovery.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Produces active Kademlia lookup candidates without owning protocol-specific peer admission. +/// +/// The protocol-specific lookup key type. +/// The protocol-specific node/contact type. +public interface IKademliaDiscovery +{ + /// + /// Runs active random lookup jobs and streams nodes discovered by those lookups. + /// + /// Number of active lookup jobs to run. + /// Maximum number of candidates requested from each lookup. + /// Cancellation token that stops active discovery. + IAsyncEnumerable DiscoverNodes(int concurrentDiscoveryJobs, int lookupResultLimit, CancellationToken token); +} diff --git a/src/Nethermind/Nethermind.Kademlia/IKademliaDistance.cs b/src/Nethermind/Nethermind.Kademlia/IKademliaDistance.cs new file mode 100644 index 000000000000..8757f4f8edfd --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IKademliaDistance.cs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Defines Kademlia XOR-distance operations for a consumer-owned key type. +/// +/// The key-space value used by the routing table. +public interface IKademliaDistance where TKadKey : notnull +{ + /// + /// The maximum log distance supported by the key space. + /// + int MaxDistance { get; } + + /// + /// The all-zero key for the key space. + /// + TKadKey Zero { get; } + + /// + /// Returns the XOR log distance between and . + /// + int CalculateLogDistance(TKadKey left, TKadKey right); + + /// + /// Compares two keys by XOR distance to . + /// + int Compare(TKadKey left, TKadKey right, TKadKey target); + + /// + /// Returns whether the bit at is set. + /// + bool GetBit(TKadKey key, int index); + + /// + /// Returns a key with the bit at set. + /// + TKadKey SetBit(TKadKey key, int index); +} diff --git a/src/Nethermind/Nethermind.Kademlia/IKademliaMessageSender.cs b/src/Nethermind/Nethermind.Kademlia/IKademliaMessageSender.cs new file mode 100644 index 000000000000..eb0f500970f2 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IKademliaMessageSender.cs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Sends protocol-specific Kademlia requests for the core table. +/// +/// The protocol-specific lookup key type. +/// The protocol-specific node/contact type. +public interface IKademliaMessageSender +{ + /// + /// Sends a liveness probe to . + /// + Task Ping(TNode receiver, CancellationToken token); + + /// + /// Requests neighbours closest to from . + /// + Task FindNeighbours(TNode receiver, TKey target, CancellationToken token); +} diff --git a/src/Nethermind/Nethermind.Kademlia/IKeyOperator.cs b/src/Nethermind/Nethermind.Kademlia/IKeyOperator.cs new file mode 100644 index 000000000000..04081bdced41 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IKeyOperator.cs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Maps protocol-specific keys and nodes to the Kademlia key space. +/// +/// The protocol-specific lookup key type. +/// The protocol-specific node/contact type. +/// The key-space value used by the routing table. +public interface IKeyOperator where TKadKey : notnull +{ + /// + /// Gets the lookup key represented by . + /// + TKey GetKey(TNode node); + + /// + /// Hashes a protocol-specific key into the fixed-width Kademlia key space. + /// + TKadKey GetKeyHash(TKey key); + + /// + /// Hashes a protocol-specific node into the fixed-width Kademlia key space. + /// + TKadKey GetNodeHash(TNode node) => GetKeyHash(GetKey(node)); + + /// + /// Creates a random protocol-specific key at the requested log distance from . + /// + TKey CreateRandomKeyAtDistance(TKadKey nodePrefix, int depth); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/ILookupAlgo.cs b/src/Nethermind/Nethermind.Kademlia/ILookupAlgo.cs similarity index 56% rename from src/Nethermind/Nethermind.Network.Discovery/Kademlia/ILookupAlgo.cs rename to src/Nethermind/Nethermind.Kademlia/ILookupAlgo.cs index 7315c29ee3c7..b48927ec6f61 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/ILookupAlgo.cs +++ b/src/Nethermind/Nethermind.Kademlia/ILookupAlgo.cs @@ -1,9 +1,8 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Core.Crypto; -namespace Nethermind.Network.Discovery.Kademlia; +namespace Nethermind.Kademlia; /// /// Main find closest-k node within the network. See the kademlia paper, 2.3. @@ -11,7 +10,9 @@ namespace Nethermind.Network.Discovery.Kademlia; /// Find closest-k is also used to determine which node should store a particular value which is used by /// store RPC (not implemented). /// -public interface ILookupAlgo +public interface ILookupAlgo + where TNode : notnull + where TKadKey : notnull { /// /// The find neighbour operation here is configurable because the same algorithm is also used for finding @@ -23,9 +24,23 @@ public interface ILookupAlgo /// /// Task Lookup( - ValueHash256 targetHash, + TKadKey targetHash, int k, Func> findNeighbourOp, CancellationToken token ); + + /// + /// Streams lookup candidates as soon as the lookup sees them. + /// + /// The hash to search near. + /// Maximum number of candidates to emit before stopping the lookup. + /// Operation that fetches neighbours from a candidate node. + /// Cancellation token. + IAsyncEnumerable LookupNodes( + TKadKey targetHash, + int maxResults, + Func> findNeighbourOp, + CancellationToken token + ); } diff --git a/src/Nethermind/Nethermind.Kademlia/INodeHashProvider.cs b/src/Nethermind/Nethermind.Kademlia/INodeHashProvider.cs new file mode 100644 index 000000000000..946f95a370a6 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/INodeHashProvider.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + + +namespace Nethermind.Kademlia; + +/// +/// Maps a node/contact to its Kademlia key-space value. +/// +/// The protocol-specific node/contact type. +/// The key-space value used by the routing table. +public interface INodeHashProvider where TKadKey : notnull +{ + /// + /// Gets the Kademlia key-space value for . + /// + TKadKey GetHash(TNode node); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHealthTracker.cs b/src/Nethermind/Nethermind.Kademlia/INodeHealthTracker.cs similarity index 82% rename from src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHealthTracker.cs rename to src/Nethermind/Nethermind.Kademlia/INodeHealthTracker.cs index 99431729de97..92ee75d17b0f 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHealthTracker.cs +++ b/src/Nethermind/Nethermind.Kademlia/INodeHealthTracker.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Network.Discovery.Kademlia; +namespace Nethermind.Kademlia; public interface INodeHealthTracker { diff --git a/src/Nethermind/Nethermind.Kademlia/IRoutingTable.cs b/src/Nethermind/Nethermind.Kademlia/IRoutingTable.cs new file mode 100644 index 000000000000..049a4f2ef6f6 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/IRoutingTable.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +public interface IRoutingTable + where TNode : notnull + where TKadKey : notnull +{ + BucketAddResult TryAddOrRefresh(in TKadKey hash, TNode item, out TNode? toRefresh); + bool Remove(in TKadKey hash); + TNode[] GetKNearestNeighbour(TKadKey hash, bool excludeSelf = false); + TNode[] GetKNearestNeighbourExcluding(TKadKey hash, TKadKey exclude, bool excludeSelf = false); + TNode[] GetAllAtDistance(int i); + IEnumerable> IterateBuckets(); + TNode? GetByHash(TKadKey nodeId); + void LogDebugInfo(); + event EventHandler? OnNodeAdded; + event EventHandler? OnNodeRemoved; + int Size { get; } +} diff --git a/src/Nethermind/Nethermind.Kademlia/KBucket.cs b/src/Nethermind/Nethermind.Kademlia/KBucket.cs new file mode 100644 index 000000000000..87bf6912df81 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/KBucket.cs @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + + +namespace Nethermind.Kademlia; + +public class KBucket(int k) + where TNode : notnull + where TKadKey : notnull +{ + public const int DefaultReplacementCacheSize = 16; + + private readonly DoubleEndedLru _items = new(k); + private readonly DoubleEndedLru _replacement = new(GetReplacementCacheSize(k)); + private readonly bool _cacheItems = k <= DefaultReplacementCacheSize; + + public int Count => _items.Count; + + private TNode[] _cachedArray = []; + + /// + /// Add or refresh a node entry. + /// Used when any traffic is received, or when seeding a node. + /// Return the last entry in a bucket to refresh when bucket is full. + /// + /// + /// + public BucketAddResult TryAddOrRefresh(in TKadKey hash, TNode item, out TNode? toRefresh) + { + BucketAddResult addResult = _items.AddOrRefresh(hash, item, out TNode? previous); + if (_cacheItems + && (addResult == BucketAddResult.Added + || (addResult == BucketAddResult.Refreshed && ShouldUpdateCachedArray(previous, item)))) + { + _cachedArray = _items.GetAll(); + } + + // Either added or refreshed + if (addResult != BucketAddResult.Full) + { + toRefresh = default; + return addResult; + } + + _replacement.AddOrRefresh(hash, item); + _items.TryGetLast(out toRefresh); + return BucketAddResult.Full; + } + + public TNode[] GetAll() => _items.GetAll(); + + internal TNode[] GetAllCached() => _cacheItems ? _cachedArray : _items.GetAll(); + + public (TKadKey, TNode)[] GetAllWithHash() => _items.GetAllWithKey(); + + internal int CopyAllWithHash((TKadKey Hash, TNode Node)[] destination) => _items.CopyAllWithKey(destination); + + public bool RemoveAndReplace(in TKadKey hash) + { + if (!_items.Remove(hash)) return false; + + if (_replacement.TryPopHead(out TKadKey replacementHash, out TNode? replacement)) + { + _items.AddOrRefresh(replacementHash, replacement!); + } + + if (_cacheItems) + { + _cachedArray = _items.GetAll(); + } + + return true; + } + + public void Clear() + { + _items.Clear(); + _replacement.Clear(); + _cachedArray = []; + } + + public bool ContainsNode(in TKadKey hash) => _items.Contains(hash); + + public TNode? GetByHash(TKadKey hash) => _items.GetByKey(hash); + + private static bool ShouldUpdateCachedArray(TNode? previous, TNode item) + => previous is not null && + (typeof(TNode).IsValueType + ? !EqualityComparer.Default.Equals(previous, item) + : !ReferenceEquals(previous, item)); + + private static int GetReplacementCacheSize(int bucketSize) + => bucketSize < DefaultReplacementCacheSize ? bucketSize : DefaultReplacementCacheSize; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucketTree.cs b/src/Nethermind/Nethermind.Kademlia/KBucketTree.cs similarity index 52% rename from src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucketTree.cs rename to src/Nethermind/Nethermind.Kademlia/KBucketTree.cs index a3d1dbf843f5..5e83c69178eb 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucketTree.cs +++ b/src/Nethermind/Nethermind.Kademlia/KBucketTree.cs @@ -1,54 +1,68 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Buffers; +using System.Runtime.CompilerServices; using System.Text; -using Nethermind.Core.Crypto; -using Nethermind.Core.Threading; +using Collections.Pooled; using Nethermind.Logging; -namespace Nethermind.Network.Discovery.Kademlia; +namespace Nethermind.Kademlia; -public class KBucketTree : IRoutingTable where TNode : notnull +public class KBucketTree : IRoutingTable + where TNode : notnull + where TKadKey : notnull { - private class TreeNode(int k, ValueHash256 prefix) + private class TreeNode(int k, TKadKey prefix) { - public KBucket Bucket { get; } = new KBucket(k); + public KBucket Bucket { get; } = new KBucket(k); public TreeNode? Left { get; set; } public TreeNode? Right { get; set; } - public ValueHash256 Prefix { get; } = prefix; - public bool IsLeaf => Left == null && Right == null; + public TKadKey Prefix { get; } = prefix; + public bool IsLeaf => Left is null && Right is null; } private readonly TreeNode _root; private readonly int _b; private readonly int _k; - private readonly ValueHash256 _currentNodeHash; + private readonly TKadKey _currentNodeHash; + private readonly IKademliaDistance _distance; private readonly ILogger _logger; - // TODO: Double check and probably make lockless - private readonly McsLock _lock = new(); + private readonly Lock _lock = new(); - public KBucketTree(KademliaConfig config, INodeHashProvider nodeHashProvider, ILogManager logManager) + public KBucketTree( + KademliaConfig config, + INodeHashProvider nodeHashProvider, + IKademliaDistance distance, + ILogManager? logManager = null) { _k = config.KSize; _b = config.Beta; + _distance = distance; _currentNodeHash = nodeHashProvider.GetHash(config.CurrentNodeId); - _root = new TreeNode(config.KSize, new ValueHash256()); - _logger = logManager.GetClassLogger>(); - if (_logger.IsDebug) _logger.Debug($"Initialized KBucketTree with k={_k}, currentNodeId={_currentNodeHash}"); + _root = new TreeNode(config.KSize, distance.Zero); + _logger = (logManager ?? NullLogManager.Instance).GetClassLogger>(); + if (_logger.IsDebug) + { + _logger.Debug($"Initialized KBucketTree with k={_k}, currentNodeId={_currentNodeHash}"); + } } - public BucketAddResult TryAddOrRefresh(in ValueHash256 nodeHash, TNode node, out TNode? toRefresh) + public BucketAddResult TryAddOrRefresh(in TKadKey nodeHash, TNode node, out TNode? toRefresh) { BucketAddResult resp; bool fireAdded; - using (_lock.Acquire()) + lock (_lock) { - if (_logger.IsDebug) _logger.Debug($"Adding node {node} with XOR distance {Hash256XorUtils.XorDistance(_currentNodeHash, nodeHash)}"); + if (_logger.IsDebug) + { + _logger.Debug($"Adding node {node} with XOR distance {_distance.CalculateLogDistance(_currentNodeHash, nodeHash)}"); + } TreeNode current = _root; // As in, what would be the depth of the node assuming all branch on the traversal is populated. - int logDistance = Hash256XorUtils.MaxDistance - Hash256XorUtils.CalculateLogDistance(_currentNodeHash, nodeHash); + int logDistance = _distance.MaxDistance - _distance.CalculateLogDistance(_currentNodeHash, nodeHash); int depth = 0; while (true) { @@ -74,7 +88,7 @@ public BucketAddResult TryAddOrRefresh(in ValueHash256 nodeHash, TNode node, out break; } - bool goRight = GetBit(nodeHash, depth); + bool goRight = _distance.GetBit(nodeHash, depth); if (_logger.IsTrace) _logger.Trace($"Traversing {(goRight ? "right" : "left")} at depth {depth}"); current = goRight ? current.Right! : current.Left!; @@ -86,13 +100,15 @@ public BucketAddResult TryAddOrRefresh(in ValueHash256 nodeHash, TNode node, out return resp; } - public TNode? GetByHash(ValueHash256 hash) + public TNode? GetByHash(TKadKey hash) { - using McsLock.Disposable _ = _lock.Acquire(); - return GetBucketForHash(hash).GetByHash(hash); + lock (_lock) + { + return GetBucketForHash(hash).GetByHash(hash); + } } - private KBucket GetBucketForHash(ValueHash256 nodeHash) + private KBucket GetBucketForHash(TKadKey nodeHash) { TreeNode current = _root; int depth = 0; @@ -104,7 +120,7 @@ private KBucket GetBucketForHash(ValueHash256 nodeHash) return current.Bucket; } - bool goRight = GetBit(nodeHash, depth); + bool goRight = _distance.GetBit(nodeHash, depth); if (_logger.IsDebug) _logger.Debug($"Traversing {(goRight ? "right" : "left")} at depth {depth}"); current = goRight ? current.Right! : current.Left!; @@ -114,7 +130,7 @@ private KBucket GetBucketForHash(ValueHash256 nodeHash) private bool ShouldSplit(int depth, int targetLogDistance) { - bool shouldSplit = depth < 256 && targetLogDistance + _b >= depth; + bool shouldSplit = depth < _distance.MaxDistance && targetLogDistance + _b >= depth; if (_logger.IsDebug) _logger.Debug($"ShouldSplit at depth {depth}: {shouldSplit}"); return shouldSplit; } @@ -122,35 +138,33 @@ private bool ShouldSplit(int depth, int targetLogDistance) private void SplitBucket(int depth, TreeNode node) { node.Left = new TreeNode(_k, node.Prefix); - byte[] rightPrefixBytes = node.Prefix.Bytes.ToArray(); - rightPrefixBytes[depth / 8] |= (byte)(1 << (7 - (depth % 8))); - node.Right = new TreeNode(_k, new ValueHash256(rightPrefixBytes)); + node.Right = new TreeNode(_k, _distance.SetBit(node.Prefix, depth)); if (_logger.IsDebug) _logger.Debug($"Created children at depth {depth + 1}"); // Iterate from oldest to newest so the new buckets preserve original LRU order. - (ValueHash256, TNode)[] items = node.Bucket.GetAllWithHash(); + (TKadKey, TNode)[] items = node.Bucket.GetAllWithHash(); for (int i = items.Length - 1; i >= 0; i--) { - (ValueHash256 itemHash, TNode value) = items[i]; - TreeNode? targetNode = GetBit(itemHash, depth) ? node.Right : node.Left; + (TKadKey itemHash, TNode value) = items[i]; + TreeNode? targetNode = _distance.GetBit(itemHash, depth) ? node.Right : node.Left; targetNode.Bucket.TryAddOrRefresh(itemHash, value, out _); - if (_logger.IsDebug) _logger.Debug($"Moved item ({itemHash}, {value}) to {(GetBit(itemHash, depth) ? "right" : "left")} child"); + if (_logger.IsDebug) _logger.Debug($"Moved item ({itemHash}, {value}) to {(_distance.GetBit(itemHash, depth) ? "right" : "left")} child"); } node.Bucket.Clear(); if (_logger.IsDebug) _logger.Debug($"Finished splitting bucket. Left count: {node.Left.Bucket.Count}, Right count: {node.Right.Bucket.Count}"); } - public bool Remove(in ValueHash256 nodeHash) + public bool Remove(in TKadKey nodeHash) { bool removed; TNode? removedNode; - using (_lock.Acquire()) + lock (_lock) { - if (_logger.IsDebug) _logger.Debug($"Attempting to remove node {nodeHash} with hash {nodeHash}"); + if (_logger.IsDebug) _logger.Debug($"Attempting to remove node {nodeHash}"); - KBucket bucket = GetBucketForHash(nodeHash); + KBucket bucket = GetBucketForHash(nodeHash); removedNode = bucket.GetByHash(nodeHash); removed = bucket.RemoveAndReplace(nodeHash); } @@ -161,25 +175,37 @@ public bool Remove(in ValueHash256 nodeHash) public TNode[] GetAllAtDistance(int distance) { - using McsLock.Disposable _ = _lock.Acquire(); + lock (_lock) + { + if (_logger.IsDebug) _logger.Debug($"Getting all nodes at distance {distance}"); + using PooledList result = new(_k); + (TKadKey Hash, TNode Node)[] bucketEntries = ArrayPool<(TKadKey Hash, TNode Node)>.Shared.Rent(_k); + try + { + GetAllAtDistanceRecursive(_root, 0, distance, result, bucketEntries); + if (_logger.IsDebug) _logger.Debug($"Found {result.Count} nodes at distance {distance}"); - if (_logger.IsDebug) _logger.Debug($"Getting all nodes at distance {distance}"); - List result = []; - GetAllAtDistanceRecursive(_root, 0, distance, result); - if (_logger.IsDebug) _logger.Debug($"Found {result.Count} nodes at distance {distance}"); - return [.. result]; + return result.Span.ToArray(); + } + finally + { + ArrayPool<(TKadKey Hash, TNode Node)>.Shared.Return(bucketEntries, RuntimeHelpers.IsReferenceOrContainsReferences<(TKadKey Hash, TNode Node)>()); + } + } } - private void GetAllAtDistanceRecursive(TreeNode node, int depth, int distance, List result) + private void GetAllAtDistanceRecursive(TreeNode node, int depth, int distance, PooledList result, (TKadKey Hash, TNode Node)[] bucketEntries) { - int targetDepth = Hash256XorUtils.MaxDistance - distance; + int targetDepth = _distance.MaxDistance - distance; if (node.IsLeaf) { if (depth <= targetDepth) { - foreach ((ValueHash256 hash, TNode item) in node.Bucket.GetAllWithHash()) + int entryCount = node.Bucket.CopyAllWithHash(bucketEntries); + for (int i = 0; i < entryCount; i++) { - if (Hash256XorUtils.CalculateLogDistance(hash, _currentNodeHash) == distance) + (TKadKey hash, TNode item) = bucketEntries[i]; + if (_distance.CalculateLogDistance(hash, _currentNodeHash) == distance) { result.Add(item); } @@ -187,84 +213,89 @@ private void GetAllAtDistanceRecursive(TreeNode node, int depth, int distance, L } else { - result.AddRange(node.Bucket.GetAll()); + TNode[] items = node.Bucket.GetAllCached(); + for (int i = 0; i < items.Length; i++) + { + result.Add(items[i]); + } } } else { if (depth < targetDepth) { - bool goRight = GetBit(_currentNodeHash, depth); + bool goRight = _distance.GetBit(_currentNodeHash, depth); if (goRight) { - GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result); + GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result, bucketEntries); } else { - GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result); + GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result, bucketEntries); } } else if (depth == targetDepth) { - bool goRight = GetBit(_currentNodeHash, depth); + bool goRight = _distance.GetBit(_currentNodeHash, depth); // Note: We go the opposite direction here, as the same direction would have a distance + 1 if (goRight) { - GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result); + GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result, bucketEntries); } else { - GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result); + GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result, bucketEntries); } } else { - GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result); - GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result); + GetAllAtDistanceRecursive(node.Left!, depth + 1, distance, result, bucketEntries); + GetAllAtDistanceRecursive(node.Right!, depth + 1, distance, result, bucketEntries); } } } - public IEnumerable<(ValueHash256 Prefix, int Distance, KBucket Bucket)> IterateBuckets() + public IEnumerable> IterateBuckets() { - using McsLock.Disposable _ = _lock.Acquire(); - - // Well, it need to ToArray, otherwise the lock does not really do anything. - return DoIterateBucketRandomHashes(_root, 0).ToArray(); + lock (_lock) + { + // Materialize snapshots while holding the tree lock so callers cannot observe live bucket state. + return DoIterateBucketRandomHashes(_root, 0).ToArray(); + } } - private IEnumerable<(ValueHash256 Prefix, int Distance, KBucket Bucket)> DoIterateBucketRandomHashes(TreeNode node, int depth) + private IEnumerable> DoIterateBucketRandomHashes(TreeNode node, int depth) { if (node.IsLeaf) { - yield return (node.Prefix, depth, node.Bucket); + yield return new RoutingTableBucket(node.Prefix, depth, node.Bucket.GetAll()); } else { - foreach ((ValueHash256 Prefix, int Distance, KBucket Bucket) bucketInfo in DoIterateBucketRandomHashes(node.Left!, depth + 1)) + foreach (RoutingTableBucket bucketInfo in DoIterateBucketRandomHashes(node.Left!, depth + 1)) { yield return bucketInfo; } - foreach ((ValueHash256 Prefix, int Distance, KBucket Bucket) bucketInfo in DoIterateBucketRandomHashes(node.Right!, depth + 1)) + foreach (RoutingTableBucket bucketInfo in DoIterateBucketRandomHashes(node.Right!, depth + 1)) { yield return bucketInfo; } } } - private IEnumerable<(ValueHash256, TNode)> IterateNeighbour(ValueHash256 hash) + private IEnumerable<(TKadKey, TNode)> IterateNeighbour(TKadKey hash) { foreach (TreeNode treeNode in IterateNodeFromClosestToTarget(_root, 0, hash)) { - foreach ((ValueHash256, TNode) entry in treeNode.Bucket.GetAllWithHash()) + foreach ((TKadKey, TNode) entry in treeNode.Bucket.GetAllWithHash()) { yield return entry; } } } - private IEnumerable IterateNodeFromClosestToTarget(TreeNode currentNode, int depth, ValueHash256 target) + private IEnumerable IterateNodeFromClosestToTarget(TreeNode currentNode, int depth, TKadKey target) { if (currentNode.IsLeaf) { @@ -272,7 +303,7 @@ private IEnumerable IterateNodeFromClosestToTarget(TreeNode currentNod } else { - if (GetBit(target, depth)) + if (_distance.GetBit(target, depth)) { foreach (TreeNode treeNode in IterateNodeFromClosestToTarget(currentNode.Right!, depth + 1, target)) { @@ -299,46 +330,43 @@ private IEnumerable IterateNodeFromClosestToTarget(TreeNode currentNod } } - public TNode[] GetKNearestNeighbour(ValueHash256 hash, ValueHash256? exclude, bool excludeSelf) - { - using McsLock.Disposable _ = _lock.Acquire(); + public TNode[] GetKNearestNeighbour(TKadKey hash, bool excludeSelf = false) => GetKNearestNeighbour(hash, default!, false, excludeSelf); - KBucket firstBucket = GetBucketForHash(hash); - bool shouldNotContainExcludedNode = exclude == null || !firstBucket.ContainsNode(exclude.Value); - bool shouldNotContainSelf = !excludeSelf || !firstBucket.ContainsNode(_currentNodeHash); + public TNode[] GetKNearestNeighbourExcluding(TKadKey hash, TKadKey exclude, bool excludeSelf = false) => GetKNearestNeighbour(hash, exclude, true, excludeSelf); - if (shouldNotContainExcludedNode && shouldNotContainSelf) + private TNode[] GetKNearestNeighbour(TKadKey hash, TKadKey exclude, bool hasExclude, bool excludeSelf) + { + lock (_lock) { - TNode[] nodes = firstBucket.GetAll(); - if (nodes.Length == _k) + KBucket firstBucket = GetBucketForHash(hash); + bool shouldNotContainExcludedNode = !hasExclude || !firstBucket.ContainsNode(exclude); + bool shouldNotContainSelf = !excludeSelf || !firstBucket.ContainsNode(_currentNodeHash); + + if (shouldNotContainExcludedNode && shouldNotContainSelf) { - // Fast path. In theory, most of the time, this would be the taken path, where no array - // concatenation or creation is needed. - return nodes; + TNode[] nodes = firstBucket.GetAllCached(); + if (nodes.Length == _k) + { + // Fast path. In theory, most of the time, this avoids neighbour traversal and concatenation. + return (TNode[])nodes.Clone(); + } } - } - - TNode[] resultArr = new TNode[_k]; - int count = 0; - foreach ((ValueHash256 itemHash, TNode item) in IterateNeighbour(hash)) - { - if (exclude != null && itemHash == exclude.Value) continue; - if (excludeSelf && itemHash == _currentNodeHash) continue; - resultArr[count++] = item; - if (count == _k) break; - } - if (count == _k) return resultArr; - TNode[] truncated = new TNode[count]; - Array.Copy(resultArr, truncated, count); - return truncated; - } + TNode[] resultArr = new TNode[_k]; + int count = 0; + foreach ((TKadKey itemHash, TNode item) in IterateNeighbour(hash)) + { + if (hasExclude && EqualityComparer.Default.Equals(itemHash, exclude)) continue; + if (excludeSelf && EqualityComparer.Default.Equals(itemHash, _currentNodeHash)) continue; + resultArr[count++] = item; + if (count == _k) break; + } - private bool GetBit(ValueHash256 hash, int index) - { - int byteIndex = index / 8; - int bitIndex = index % 8; - return (hash.Bytes[byteIndex] & (1 << (7 - bitIndex))) != 0; + if (count == _k) return resultArr; + TNode[] truncated = new TNode[count]; + Array.Copy(resultArr, truncated, count); + return truncated; + } } private void LogTreeStructureRecursive(TreeNode node, string indent, bool last, int depth, StringBuilder sb) @@ -355,7 +383,7 @@ private void LogTreeStructureRecursive(TreeNode node, string indent, bool last, indent += "│ "; } - if (node.Left == null && node.Right == null) + if (node.Left is null && node.Right is null) { sb.AppendLine($"Bucket (Depth: {depth}, Count: {node.Bucket.Count})"); return; @@ -380,7 +408,7 @@ void TraverseTree(TreeNode node, int depth) totalNodes++; maxDepth = Math.Max(maxDepth, depth); - if (node.Left == null && node.Right == null) + if (node.Left is null && node.Right is null) { totalBuckets++; totalItems += node.Bucket.Count; @@ -394,12 +422,7 @@ void TraverseTree(TreeNode node, int depth) TraverseTree(_root, 0); - _logger.Debug($"Tree Statistics:\n" + - $"Total Nodes: {totalNodes}\n" + - $"Total Buckets: {totalBuckets}\n" + - $"Max Depth: {maxDepth}\n" + - $"Total Items: {totalItems}\n" + - $"Average Items per Bucket: {(double)totalItems / totalBuckets:F2}"); + _logger.Debug($"Tree Statistics: Total Nodes: {totalNodes}, Total Buckets: {totalBuckets}, Max Depth: {maxDepth}, Total Items: {totalItems}, Average Items per Bucket: {(double)totalItems / totalBuckets:F2}"); } private void LogTreeStructure() @@ -408,11 +431,13 @@ private void LogTreeStructure() StringBuilder sb = new(); LogTreeStructureRecursive(_root, "", true, 0, sb); - _logger.Trace($"Current Tree Structure:\n{sb}"); + _logger.Trace($"Current Tree Structure:{Environment.NewLine}{sb}"); } public void LogDebugInfo() { + if (!_logger.IsDebug) return; + LogTreeStatistics(); LogTreeStructure(); } @@ -425,11 +450,24 @@ public int Size get { int total = 0; - foreach ((ValueHash256 Prefix, int Distance, KBucket Bucket) in IterateBuckets()) + lock (_lock) { - total += Bucket.Count; + CountNodes(_root); } + return total; + + void CountNodes(TreeNode node) + { + if (node.IsLeaf) + { + total += node.Bucket.Count; + return; + } + + CountNodes(node.Left!); + CountNodes(node.Right!); + } } } } diff --git a/src/Nethermind/Nethermind.Kademlia/Kademlia.cs b/src/Nethermind/Nethermind.Kademlia/Kademlia.cs new file mode 100644 index 000000000000..14186c4d513f --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/Kademlia.cs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics; +using Collections.Pooled; +using Nethermind.Logging; + +namespace Nethermind.Kademlia; + +/// +/// Maintains a Kademlia routing table and runs network lookups through caller-provided message transport. +/// +public class Kademlia : IKademlia + where TNode : notnull + where TKadKey : notnull +{ + private readonly IKademliaMessageSender _kademliaMessageSender; + private readonly IKeyOperator _keyOperator; + private readonly IRoutingTable _routingTable; + private readonly ILookupAlgo _lookupAlgo; + private readonly INodeHealthTracker _nodeHealthTracker; + private readonly ILogger _logger; + + private readonly TNode _currentNodeId; + private readonly TKadKey _currentNodeIdAsHash; + private readonly int _kSize; + private readonly TimeSpan _refreshInterval; + private readonly TimeSpan _bucketRefreshInterval; + private readonly IReadOnlyList _bootNodes; + private readonly TimeProvider _timeProvider; + private readonly Dictionary _lastBucketRefreshTicks = []; + private readonly Lock _lastBucketRefreshLock = new(); + + /// + /// Creates a Kademlia table over the supplied routing, lookup, health, and transport abstractions. + /// + public Kademlia( + IKeyOperator keyOperator, + IKademliaMessageSender sender, + IRoutingTable routingTable, + ILookupAlgo lookupAlgo, + INodeHealthTracker nodeHealthTracker, + KademliaConfig config, + ILogManager? logManager = null, + TimeProvider? timeProvider = null) + { + _keyOperator = keyOperator; + _kademliaMessageSender = sender; + _routingTable = routingTable; + _lookupAlgo = lookupAlgo; + _nodeHealthTracker = nodeHealthTracker; + _logger = (logManager ?? NullLogManager.Instance).GetClassLogger>(); + + _currentNodeId = config.CurrentNodeId; + _currentNodeIdAsHash = _keyOperator.GetNodeHash(_currentNodeId); + _kSize = config.KSize; + _refreshInterval = config.RefreshInterval; + _bucketRefreshInterval = config.BucketRefreshInterval; + _bootNodes = config.BootNodes; + _timeProvider = timeProvider ?? TimeProvider.System; + + AddOrRefresh(_currentNodeId); + for (int i = 0; i < _bootNodes.Count; i++) + { + AddOrRefresh(_bootNodes[i]); + } + } + + public TNode CurrentNode => _currentNodeId; + + public void AddOrRefresh(TNode node) => _nodeHealthTracker.OnIncomingMessageFrom(node); + + public void Remove(TNode node) => _routingTable.Remove(_keyOperator.GetNodeHash(node)); + + public TNode[] GetAllAtDistance(int i) => _routingTable.GetAllAtDistance(i); + + private bool SameAsSelf(TNode node) => EqualityComparer.Default.Equals(_keyOperator.GetNodeHash(node), _currentNodeIdAsHash); + + public Task LookupNodesClosest(TKey key, CancellationToken token, int? k = null) + { + TKadKey keyHash = _keyOperator.GetKeyHash(key); + return _lookupAlgo.Lookup( + keyHash, + k ?? _kSize, + (nextNode, token) => FindNeighbours(key, keyHash, nextNode, token), + token + ); + } + + public IAsyncEnumerable LookupNodes(TKey key, CancellationToken token, int? maxResults = null) + { + TKadKey keyHash = _keyOperator.GetKeyHash(key); + return _lookupAlgo.LookupNodes( + keyHash, + maxResults ?? _kSize, + (nextNode, token) => FindNeighbours(key, keyHash, nextNode, token), + token + ); + } + + private async Task FindNeighbours(TKey key, TKadKey keyHash, TNode nextNode, CancellationToken token) + { + if (SameAsSelf(nextNode)) + { + return _routingTable.GetKNearestNeighbour(keyHash); + } + + return await _kademliaMessageSender.FindNeighbours(nextNode, key, token); + } + + public async Task Run(CancellationToken token) + { + while (true) + { + try + { + await Bootstrap(token); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + if (_logger.IsError) _logger.Error("Bootstrap iteration failed.", e); + } + + await Task.Delay(_refreshInterval, token); + } + } + + public async Task Bootstrap(CancellationToken token) + { + Stopwatch sw = Stopwatch.StartNew(); + + int onlineBootNodes = 0; + + // Check bootnodes is online + await Parallel.ForEachAsync(_bootNodes, token, async (node, token) => + { + try + { + // Should be added on Pong. + if (await _kademliaMessageSender.Ping(node, token)) + { + Interlocked.Increment(ref onlineBootNodes); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + if (_logger.IsDebug) _logger.Debug($"Bootnode ping failed for {node}: {e}"); + } + }); + + if (_logger.IsDebug) + { + _logger.Debug($"Online bootnodes: {onlineBootNodes}"); + } + + TKey currentNodeIdAsKey = _keyOperator.GetKey(_currentNodeId); + await LookupNodesClosest(currentNodeIdAsKey, token); + + token.ThrowIfCancellationRequested(); + + // Refresh stale non-empty buckets one by one. Protocols whose wire lookup target cannot be synthesized from a + // bucket prefix may return a best-effort random lookup key here; discv4 public keys are one example. + using PooledSet activeBucketPrefixes = new(); + foreach (RoutingTableBucket bucket in _routingTable.IterateBuckets()) + { + activeBucketPrefixes.Add(bucket.Prefix); + if (!ShouldRefreshBucket(bucket.Prefix, bucket.Count)) continue; + + TKey? keyToLookup = _keyOperator.CreateRandomKeyAtDistance(bucket.Prefix, bucket.Distance); + await LookupNodesClosest(keyToLookup, token); + } + + PruneLastBucketRefreshTicks(activeBucketPrefixes); + + if (_logger.IsDebug) + { + _logger.Debug($"Bootstrap completed. Took {sw.Elapsed}."); + _routingTable.LogDebugInfo(); + } + } + + private bool ShouldRefreshBucket(TKadKey prefix, int bucketCount) + { + if (bucketCount == 0) return false; + + long nowTicks = _timeProvider.GetUtcNow().Ticks; + lock (_lastBucketRefreshLock) + { + if (_lastBucketRefreshTicks.TryGetValue(prefix, out long lastRefreshTicks) && + nowTicks - lastRefreshTicks < _bucketRefreshInterval.Ticks) + { + return false; + } + + _lastBucketRefreshTicks[prefix] = nowTicks; + return true; + } + } + + private void PruneLastBucketRefreshTicks(ISet activeBucketPrefixes) + { + lock (_lastBucketRefreshLock) + { + // Dictionary.Remove is safe during key enumeration since .NET Core 3.0. + foreach (TKadKey prefix in _lastBucketRefreshTicks.Keys) + { + if (!activeBucketPrefixes.Contains(prefix)) + { + _lastBucketRefreshTicks.Remove(prefix); + } + } + } + } + + public TNode[] GetKNeighbour(TKey target, TNode? excluding = default, bool excludeSelf = false) + { + TKadKey hash = _keyOperator.GetKeyHash(target); + if (excluding is null) + { + return _routingTable.GetKNearestNeighbour(hash, excludeSelf); + } + + TKadKey excludeHash = _keyOperator.GetNodeHash(excluding); + return _routingTable.GetKNearestNeighbourExcluding(hash, excludeHash, excludeSelf); + } + + public event EventHandler OnNodeAdded + { + add => _routingTable.OnNodeAdded += value; + remove => _routingTable.OnNodeAdded -= value; + } + + public event EventHandler OnNodeRemoved + { + add => _routingTable.OnNodeRemoved += value; + remove => _routingTable.OnNodeRemoved -= value; + } + + public IEnumerable IterateNodes() + { + foreach (RoutingTableBucket bucket in _routingTable.IterateBuckets()) + { + foreach (TNode node in bucket.Nodes) + { + yield return node; + } + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaConfig.cs b/src/Nethermind/Nethermind.Kademlia/KademliaConfig.cs similarity index 76% rename from src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaConfig.cs rename to src/Nethermind/Nethermind.Kademlia/KademliaConfig.cs index b8ac4a4fbb5e..9a2a7a529223 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaConfig.cs +++ b/src/Nethermind/Nethermind.Kademlia/KademliaConfig.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Network.Discovery.Kademlia; +namespace Nethermind.Kademlia; public class KademliaConfig { @@ -45,6 +45,16 @@ public class KademliaConfig /// public int NodeRequestFailureThreshold { get; set; } = 5; + /// + /// Maximum time to wait for active refresh pings while disposing the node health tracker. + /// + public TimeSpan RefreshPingTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Delay before pinging the oldest node in a full bucket to give incoming traffic a chance to refresh it first. + /// + public TimeSpan RefreshPingDelay { get; set; } = TimeSpan.FromMilliseconds(100); + /// /// Starting boot nodes. /// diff --git a/src/Nethermind/Nethermind.Kademlia/LookupKNearestNeighbour.cs b/src/Nethermind/Nethermind.Kademlia/LookupKNearestNeighbour.cs new file mode 100644 index 000000000000..8e65bfdcd54d --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/LookupKNearestNeighbour.cs @@ -0,0 +1,375 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Nethermind.Logging; + +namespace Nethermind.Kademlia; + +/// +/// This find nearest k query does not follow the kademlia paper faithfully. Instead of distinct rounds, it has +/// num worker where alpha is the number of worker. Worker does not wait for other worker. Stop condition +/// happens if no more node to query or no new node can be added to the current result set that can improve it +/// for more than alpha*2 request. It is slightly faster than the legacy query on find value where it can be cancelled +/// earlier as it converge to the content faster, but take more query for findnodes due to a more strict stop +/// condition. +/// +public class LookupKNearestNeighbour( + IRoutingTable routingTable, + INodeHashProvider nodeHashProvider, + IKademliaDistance distance, + INodeHealthTracker nodeHealthTracker, + KademliaConfig config, + ILogManager? logManager = null) : ILookupAlgo + where TNode : notnull + where TKadKey : notnull +{ + private readonly TimeSpan _findNeighbourHardTimeout = config.LookupFindNeighbourHardTimeout; + private readonly ILogger _logger = (logManager ?? NullLogManager.Instance).GetClassLogger>(); + + public async Task Lookup( + TKadKey targetHash, + int k, + Func> findNeighbourOp, + CancellationToken token + ) + => await LookupCore(targetHash, k, findNeighbourOp, null, token); + + public async IAsyncEnumerable LookupNodes( + TKadKey targetHash, + int maxResults, + Func> findNeighbourOp, + [EnumeratorCancellation] CancellationToken token + ) + { + if (maxResults <= 0) + { + yield break; + } + + Channel results = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); + int emitted = 0; + Task producer = ProduceResults(); + + try + { + await foreach (TNode node in results.Reader.ReadAllAsync(token)) + { + yield return node; + } + + await producer; + } + finally + { + await cts.CancelAsync(); + try + { + await producer; + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + } + } + + async Task ProduceResults() + { + Exception? error = null; + try + { + _ = await LookupCore(targetHash, maxResults, findNeighbourOp, Publish, cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + } + catch (Exception e) + { + error = e; + } + finally + { + results.Writer.TryComplete(error); + } + } + + bool Publish(TNode node) + { + int count = Interlocked.Increment(ref emitted); + if (count <= maxResults) + { + results.Writer.TryWrite(node); + } + + if (count >= maxResults) + { + cts.Cancel(); + return false; + } + + return true; + } + } + + private async Task LookupCore( + TKadKey targetHash, + int k, + Func> findNeighbourOp, + Func? publishNode, + CancellationToken token + ) + { + if (_logger.IsDebug) + { + _logger.Debug($"Initiate lookup for hash {targetHash}"); + } + + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); + token = cts.Token; + + ConcurrentDictionary queried = new(); + ConcurrentDictionary seen = new(); + + IComparer comparer = Comparer.Create((h1, h2) => + distance.Compare(h1, h2, targetHash)); + IComparer comparerReverse = Comparer.Create((h1, h2) => + distance.Compare(h2, h1, targetHash)); + + Lock queueLock = new(); + + // Ordered by lowest distance. Will get popped for next round. + PriorityQueue<(TKadKey, TNode), TKadKey> bestSeen = new(comparer); + + // Ordered by highest distance. Added on result. Get popped as result. + PriorityQueue<(TKadKey, TNode), TKadKey> finalResult = new(comparerReverse); + + TaskCompletionSource roundComplete = new(TaskCreationOptions.RunContinuationsAsynchronously); + int closestNodeRound = 0; + int currentRound = 0; + int queryingTask = 0; + bool finished = false; + + foreach (TNode node in routingTable.GetKNearestNeighbour(targetHash)) + { + TKadKey nodeHash = nodeHashProvider.GetHash(node); + if (!seen.TryAdd(nodeHash, node)) + { + continue; + } + + bestSeen.Enqueue((nodeHash, node), nodeHash); + if (!TryPublish(node)) + { + Volatile.Write(ref finished, true); + break; + } + } + + Task[] workers = new Task[config.Alpha]; + for (int i = 0; i < workers.Length; i++) + { + workers[i] = Task.Run(async () => + { + while (!Volatile.Read(ref finished)) + { + token.ThrowIfCancellationRequested(); + if (!TryGetNodeToQuery(out TKadKey toQueryHash, out TNode toQueryNode)) + { + if (queryingTask > 0) + { + // Need to wait for all querying tasks first here. + await Task.WhenAny(Volatile.Read(ref roundComplete).Task, Task.Delay(100, token)); + continue; + } + + // No node to query and running query. + if (_logger.IsTrace) _logger.Trace("Stopping lookup. No node to query."); + break; + } + + try + { + if (ShouldStopDueToNoBetterResult(out int round)) + { + if (_logger.IsTrace) _logger.Trace("Stopping lookup. No better result."); + break; + } + + queried.TryAdd(toQueryHash, toQueryNode); + TNode[]? neighbours = await WrappedFindNeighbourOp(toQueryNode); + if (neighbours is null) continue; + + ProcessResult(toQueryHash, toQueryNode, neighbours, round); + } + finally + { + Interlocked.Decrement(ref queryingTask); + TaskCompletionSource current = Volatile.Read(ref roundComplete); + if (current.TrySetResult()) + { + Interlocked.CompareExchange( + ref roundComplete, + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + current); + } + } + } + }, token); + } + + // When any of the worker is finished, we consider the whole query as done. + // This prevent this operation from hanging on a timed out request + await Task.WhenAny(workers); + Volatile.Write(ref finished, true); + await cts.CancelAsync(); + try + { + await Task.WhenAll(workers); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + + return publishNode is null ? CompileResult() : []; + + async Task WrappedFindNeighbourOp(TNode node) + { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(_findNeighbourHardTimeout); + + try + { + // targetHash is implied in findNeighbourOp + TNode[]? ret = await findNeighbourOp(node, cts.Token); + if (ret is null) return null; + + nodeHealthTracker.OnIncomingMessageFrom(node); + + return ret; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + nodeHealthTracker.OnRequestFailed(node); + return null; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + nodeHealthTracker.OnRequestFailed(node); + if (_logger.IsWarn) _logger.Warn($"Find neighbour op failed: {e}"); + return null; + } + } + + bool TryGetNodeToQuery(out TKadKey hash, out TNode node) + { + lock (queueLock) + { + if (bestSeen.Count == 0) + { + hash = default!; + node = default!; + // No more node to query. + // Note: its possible that there are other worker currently which may add to bestSeen. + return false; + } + + Interlocked.Increment(ref queryingTask); + (hash, node) = bestSeen.Dequeue(); + return true; + } + } + + void ProcessResult(TKadKey hash, TNode toQuery, TNode[] neighbours, int round) + { + lock (queueLock) + { + finalResult.Enqueue((hash, toQuery), hash); + while (finalResult.Count > k) + { + finalResult.Dequeue(); + } + + foreach (TNode neighbour in neighbours) + { + TKadKey neighbourHash = nodeHashProvider.GetHash(neighbour); + + // Already queried, we ignore + if (queried.ContainsKey(neighbourHash)) continue; + + // When seen already dont record + if (!seen.TryAdd(neighbourHash, neighbour)) continue; + + bestSeen.Enqueue((neighbourHash, neighbour), neighbourHash); + if (!TryPublish(neighbour)) + { + Volatile.Write(ref finished, true); + break; + } + + if (closestNodeRound < round) + { + if (finalResult.Count < k) + { + closestNodeRound = round; + } + + // If the worst item in final result is worst that this neighbour, update closes node round + if (finalResult.TryPeek(out (TKadKey hash, TNode node) worstResult, out _) && comparer.Compare(neighbourHash, worstResult.hash) < 0) + { + closestNodeRound = round; + } + } + } + } + } + + TNode[] CompileResult() + { + lock (queueLock) + { + if (finalResult.Count > k) finalResult.Dequeue(); + TNode[] result = new TNode[finalResult.Count]; + int i = 0; + foreach (((TKadKey, TNode) Element, TKadKey Priority) entry in finalResult.UnorderedItems) + { + result[i++] = entry.Element.Item2; + } + + return result; + } + } + + bool ShouldStopDueToNoBetterResult(out int round) + { + lock (queueLock) + { + round = Interlocked.Increment(ref currentRound); + if (finalResult.Count >= k && round - closestNodeRound >= (config.Alpha * 2)) + { + // No closer node for more than or equal to _alpha*2 round. + // Assume exit condition + // Why not just _alpha? + // Because there could be currently running work that may increase closestNodeRound. + // So including this worker, assume no more + if (_logger.IsTrace) _logger.Trace($"No more closer node. Round: {round}, closestNodeRound {closestNodeRound}"); + return true; + } + + return false; + } + } + + bool TryPublish(TNode node) => publishNode?.Invoke(node) ?? true; + } +} diff --git a/src/Nethermind/Nethermind.Kademlia/Nethermind.Kademlia.csproj b/src/Nethermind/Nethermind.Kademlia/Nethermind.Kademlia.csproj new file mode 100644 index 000000000000..6995c2b9edb6 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/Nethermind.Kademlia.csproj @@ -0,0 +1,16 @@ + + + + enable + enable + + + + + + + + + + + diff --git a/src/Nethermind/Nethermind.Kademlia/NodeHealthTracker.cs b/src/Nethermind/Nethermind.Kademlia/NodeHealthTracker.cs new file mode 100644 index 000000000000..d79e34aa038e --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/NodeHealthTracker.cs @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Concurrent; +using Nethermind.Logging; + +namespace Nethermind.Kademlia; + +/// +/// Tracks node liveness signals and evicts peers that repeatedly fail Kademlia requests. +/// +public class NodeHealthTracker( + KademliaConfig config, + IRoutingTable routingTable, + INodeHashProvider nodeHashProvider, + IKademliaMessageSender kademliaMessageSender, + ILogManager? logManager = null +) : INodeHealthTracker, IDisposable, IAsyncDisposable + where TNode : notnull + where TKadKey : notnull +{ + private readonly ILogger _logger = (logManager ?? NullLogManager.Instance).GetClassLogger>(); + + private readonly ConcurrentDictionary _isRefreshing = new(); + private readonly ConcurrentDictionary _refreshTasks = new(); + private readonly PeerFailureCache _peerFailures = new(1024); + private readonly TKadKey _currentNodeIdAsHash = nodeHashProvider.GetHash(config.CurrentNodeId); + private readonly TimeSpan _refreshPingTimeout = config.RefreshPingTimeout; + private readonly TimeSpan _refreshPingDelay = config.RefreshPingDelay; + private readonly CancellationTokenSource _refreshCancellation = new(); + + private int _disposed; + + private bool SameAsSelf(TNode node) => EqualityComparer.Default.Equals(nodeHashProvider.GetHash(node), _currentNodeIdAsHash); + + private void TryRefresh(TNode toRefresh) + { + TKadKey nodeHash = nodeHashProvider.GetHash(toRefresh); + if (_isRefreshing.TryAdd(nodeHash, true)) + { + if (Volatile.Read(ref _disposed) != 0) + { + _isRefreshing.TryRemove(nodeHash, out _); + return; + } + + _refreshTasks[nodeHash] = RefreshAsync(toRefresh, nodeHash, _refreshCancellation.Token); + } + } + + private async Task RefreshAsync(TNode toRefresh, TKadKey nodeHash, CancellationToken token) + { + try + { + // First, we delay in case any new message come and clear the refresh task, so we don't need to send any ping. + await Task.Delay(_refreshPingDelay, token); + if (!_isRefreshing.ContainsKey(nodeHash)) + { + return; + } + + try + { + if (await kademliaMessageSender.Ping(toRefresh, token)) + { + OnIncomingMessageFrom(toRefresh); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + _isRefreshing.TryRemove(nodeHash, out _); + return; + } + catch (Exception e) + { + OnRequestFailed(toRefresh); + if (_logger.IsDebug) _logger.Debug($"Error while refreshing node {toRefresh}: {e}"); + } + + if (_isRefreshing.TryRemove(nodeHash, out _)) + { + routingTable.Remove(nodeHash); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + _isRefreshing.TryRemove(nodeHash, out _); + } + finally + { + _refreshTasks.TryRemove(nodeHash, out _); + } + } + + /// + /// Call when an incoming message from a node is received. This is used by other algorithm for health checks. + /// + /// + public void OnIncomingMessageFrom(TNode node) + { + _isRefreshing.TryRemove(nodeHashProvider.GetHash(node), out _); + + BucketAddResult addResult = routingTable.TryAddOrRefresh(nodeHashProvider.GetHash(node), node, out TNode? toRefresh); + if (addResult == BucketAddResult.Full && toRefresh is not null) + { + if (SameAsSelf(toRefresh)) + { + // Move the current node entry to the front of its bucket. + routingTable.TryAddOrRefresh(_currentNodeIdAsHash, toRefresh, out TNode? _); + } + else + { + TryRefresh(toRefresh); + } + } + _peerFailures.Delete(nodeHashProvider.GetHash(node)); + } + + /// + /// Call when a request to a node failed. This is used by other algorithm for health checks. + /// + /// + public void OnRequestFailed(TNode node) + { + TKadKey hash = nodeHashProvider.GetHash(node); + if (!_peerFailures.TryGet(hash, out int currentFailure)) + { + _peerFailures.Set(hash, 1); + return; + } + + if (currentFailure >= config.NodeRequestFailureThreshold) + { + routingTable.Remove(hash); + _peerFailures.Delete(hash); + return; + } + + _peerFailures.Set(hash, currentFailure + 1); + } + + public void Dispose() + { + Task[] refreshTasks = CancelAndGetRefreshTasks(); + if (refreshTasks.Length == 0) return; + + bool completed = false; + try + { + completed = Task.WaitAll(refreshTasks, _refreshPingTimeout + TimeSpan.FromMilliseconds(500)); + } + catch (AggregateException e) + { + completed = true; + if (!HasOnlyCancellationExceptions(e)) + { + if (_logger.IsDebug) _logger.Debug($"Error while disposing node health tracker: {e}"); + } + } + + if (completed) + { + _refreshCancellation.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + Task[] refreshTasks = CancelAndGetRefreshTasks(); + if (refreshTasks.Length == 0) return; + + bool completed = false; + try + { + await Task.WhenAll(refreshTasks).WaitAsync(_refreshPingTimeout + TimeSpan.FromMilliseconds(500)); + completed = true; + } + catch (TimeoutException) + { + } + catch (OperationCanceledException) + { + completed = true; + } + catch (Exception e) + { + completed = true; + if (_logger.IsDebug) _logger.Debug($"Error while disposing node health tracker: {e}"); + } + + if (completed) + { + _refreshCancellation.Dispose(); + } + } + + private Task[] CancelAndGetRefreshTasks() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return []; + } + + _refreshCancellation.Cancel(); + Task[] refreshTasks = new Task[_refreshTasks.Count]; + int refreshTaskCount = 0; + foreach ((_, Task refreshTask) in _refreshTasks) + { + if (refreshTaskCount == refreshTasks.Length) + { + Array.Resize(ref refreshTasks, refreshTaskCount + 1); + } + + refreshTasks[refreshTaskCount++] = refreshTask; + } + + if (refreshTaskCount == 0) + { + _refreshCancellation.Dispose(); + return []; + } + + if (refreshTaskCount != refreshTasks.Length) + { + Array.Resize(ref refreshTasks, refreshTaskCount); + } + + return refreshTasks; + } + + private static bool HasOnlyCancellationExceptions(AggregateException e) + { + foreach (Exception exception in e.InnerExceptions) + { + if (exception is not OperationCanceledException) + { + return false; + } + } + + return true; + } + + private sealed class PeerFailureCache(int capacity) + { + private readonly Lock _lock = new(); + private readonly Dictionary OrderNode)> _values = new(capacity); + private readonly LinkedList _order = []; + + public bool TryGet(TKadKey hash, out int failureCount) + { + lock (_lock) + { + if (!_values.TryGetValue(hash, out (int FailureCount, LinkedListNode OrderNode) entry)) + { + failureCount = 0; + return false; + } + + _order.Remove(entry.OrderNode); + _order.AddLast(entry.OrderNode); + failureCount = entry.FailureCount; + return true; + } + } + + public void Set(TKadKey hash, int failureCount) + { + lock (_lock) + { + if (_values.TryGetValue(hash, out (int FailureCount, LinkedListNode OrderNode) entry)) + { + _order.Remove(entry.OrderNode); + _order.AddLast(entry.OrderNode); + _values[hash] = (failureCount, entry.OrderNode); + return; + } + + LinkedListNode orderNode = _order.AddLast(hash); + _values[hash] = (failureCount, orderNode); + Trim(); + } + } + + public void Delete(TKadKey hash) + { + lock (_lock) + { + if (_values.Remove(hash, out (int FailureCount, LinkedListNode OrderNode) entry)) + { + _order.Remove(entry.OrderNode); + } + } + } + + private void Trim() + { + while (_values.Count > capacity) + { + LinkedListNode oldest = _order.First!; + + _order.RemoveFirst(); + _values.Remove(oldest.Value); + } + } + } +} diff --git a/src/Nethermind/Nethermind.Kademlia/RandomWalkKademliaDiscovery.cs b/src/Nethermind/Nethermind.Kademlia/RandomWalkKademliaDiscovery.cs new file mode 100644 index 000000000000..d03314a6b81d --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/RandomWalkKademliaDiscovery.cs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Nethermind.Logging; + +namespace Nethermind.Kademlia; + +/// +/// Runs active random Kademlia lookups and streams discovered nodes. +/// +public sealed class RandomWalkKademliaDiscovery( + IKademlia kademlia, + IKeyOperator keyOperator, + IKademliaDistance distance, + KademliaConfig kademliaConfig, + ILogManager? logManager = null) + : IKademliaDiscovery + where TNode : notnull + where TKadKey : notnull +{ + private static readonly TimeSpan MinimumIterationDuration = TimeSpan.FromSeconds(1); + + private readonly ILogger _logger = (logManager ?? NullLogManager.Instance).GetClassLogger>(); + private readonly TKadKey _currentNodeHash = keyOperator.GetNodeHash(kademliaConfig.CurrentNodeId); + private readonly int _maxDistance = distance.MaxDistance; + + /// + public IAsyncEnumerable DiscoverNodes(int concurrentDiscoveryJobs, int lookupResultLimit, CancellationToken token) + { + ArgumentOutOfRangeException.ThrowIfNegative(concurrentDiscoveryJobs); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lookupResultLimit); + + return DiscoverNodesCore(concurrentDiscoveryJobs, lookupResultLimit, token); + } + + private async IAsyncEnumerable DiscoverNodesCore( + int concurrentDiscoveryJobs, + int lookupResultLimit, + [EnumeratorCancellation] CancellationToken token) + { + if (concurrentDiscoveryJobs == 0) + { + yield break; + } + + using CancellationTokenSource disposeCts = CancellationTokenSource.CreateLinkedTokenSource(token); + CancellationToken discoveryToken = disposeCts.Token; + Channel channel = Channel.CreateBounded(lookupResultLimit); + + Task[] discoverTasks = new Task[concurrentDiscoveryJobs]; + for (int i = 0; i < discoverTasks.Length; i++) + { + discoverTasks[i] = Task.Run(() => RunDiscoveryJob(channel.Writer, lookupResultLimit, discoveryToken)); + } + + Task discoverTask = Task.WhenAll(discoverTasks); + try + { + await foreach (TNode node in channel.Reader.ReadAllAsync(token)) + { + yield return node; + } + } + finally + { + await disposeCts.CancelAsync(); + channel.Writer.TryComplete(); + try + { + await discoverTask; + } + catch (OperationCanceledException) when (discoveryToken.IsCancellationRequested) + { + } + } + } + + private async Task RunDiscoveryJob(ChannelWriter writer, int lookupResultLimit, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + Stopwatch iterationTime = Stopwatch.StartNew(); + try + { + int targetDistance = Random.Shared.Next(_maxDistance) + 1; + TKey target = keyOperator.CreateRandomKeyAtDistance(_currentNodeHash, targetDistance); + if (_logger.IsDebug) _logger.Debug($"Looking up random Kademlia target at distance {targetDistance}."); + + int count = 0; + await foreach (TNode node in kademlia.LookupNodes(target, token, lookupResultLimit).WithCancellation(token)) + { + count++; + await writer.WriteAsync(node, token); + } + + if (_logger.IsDebug) _logger.Debug($"Found {count} nodes from random Kademlia lookup."); + + if (iterationTime.Elapsed < MinimumIterationDuration) + { + await Task.Delay(MinimumIterationDuration - iterationTime.Elapsed, token); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + if (_logger.IsError) _logger.Error("Random Kademlia discovery lookup failed.", ex); + } + } + } +} diff --git a/src/Nethermind/Nethermind.Kademlia/RoutingTableBucket.cs b/src/Nethermind/Nethermind.Kademlia/RoutingTableBucket.cs new file mode 100644 index 000000000000..7bda89453d20 --- /dev/null +++ b/src/Nethermind/Nethermind.Kademlia/RoutingTableBucket.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Kademlia; + +/// +/// Snapshot of one routing-table bucket. +/// +/// Bucket prefix used by the routing table. +/// Bucket depth, expressed as the distance index used by the routing table traversal. +/// Snapshot of nodes in this bucket. Mutating the collection does not mutate the routing table. +public readonly record struct RoutingTableBucket(TKadKey Prefix, int Distance, IReadOnlyList Nodes) + where TNode : notnull + where TKadKey : notnull +{ + /// + /// Number of nodes captured in the bucket snapshot. + /// + public int Count => Nodes.Count; +} diff --git a/src/Nethermind/Nethermind.Logging.Microsoft/MicrosoftLoggerExtensions.cs b/src/Nethermind/Nethermind.Logging.Microsoft/MicrosoftLoggerExtensions.cs deleted file mode 100644 index e8e2e50b6486..000000000000 --- a/src/Nethermind/Nethermind.Logging.Microsoft/MicrosoftLoggerExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Microsoft.Extensions.Logging; - -namespace Nethermind.Logging.Microsoft -{ - public static class MicrosoftLoggerExtensions - { - public static bool IsError(this ILogger logger) => logger.IsEnabled(LogLevel.Error); - - public static bool IsWarn(this ILogger logger) => logger.IsEnabled(LogLevel.Warning); - - public static bool IsInfo(this ILogger logger) => logger.IsEnabled(LogLevel.Information); - - public static bool IsDebug(this ILogger logger) => logger.IsEnabled(LogLevel.Debug); - - public static bool IsTrace(this ILogger logger) => logger.IsEnabled(LogLevel.Trace); - } -} diff --git a/src/Nethermind/Nethermind.Logging.Microsoft/Nethermind.Logging.Microsoft.csproj b/src/Nethermind/Nethermind.Logging.Microsoft/Nethermind.Logging.Microsoft.csproj index 05492bb49b61..72bbdf828bc0 100644 --- a/src/Nethermind/Nethermind.Logging.Microsoft/Nethermind.Logging.Microsoft.csproj +++ b/src/Nethermind/Nethermind.Logging.Microsoft/Nethermind.Logging.Microsoft.csproj @@ -8,4 +8,8 @@ + + + + diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NethermindLoggerFactory.cs b/src/Nethermind/Nethermind.Logging.Microsoft/NethermindLoggerFactory.cs similarity index 74% rename from src/Nethermind/Nethermind.Network.Discovery/Discv5/NethermindLoggerFactory.cs rename to src/Nethermind/Nethermind.Logging.Microsoft/NethermindLoggerFactory.cs index 33d86362ce18..f10bb2a00ee8 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NethermindLoggerFactory.cs +++ b/src/Nethermind/Nethermind.Logging.Microsoft/NethermindLoggerFactory.cs @@ -1,21 +1,22 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using Microsoft.Extensions.Logging; -using Nethermind.Logging; +using MicrosoftLogger = Microsoft.Extensions.Logging.ILogger; using MsLogLevel = Microsoft.Extensions.Logging.LogLevel; -namespace Nethermind.Network.Discovery.Discv5; +namespace Nethermind.Logging.Microsoft; public sealed class NethermindLoggerFactory(ILogManager logManager, bool lowerLogLevel = false, MsLogLevel? maxLogLevel = null) : ILoggerFactory { - public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => new NethermindLogger(logManager.GetLogger(categoryName), lowerLogLevel, maxLogLevel); + public MicrosoftLogger CreateLogger(string categoryName) => new NethermindLoggerAdapter(logManager.GetLogger(categoryName), lowerLogLevel, maxLogLevel); public void Dispose() { } public void AddProvider(ILoggerProvider provider) { } - class NethermindLogger(Logging.ILogger logger, bool lowerLogLevel = false, MsLogLevel? maxLogLevel = null) : Microsoft.Extensions.Logging.ILogger + private sealed class NethermindLoggerAdapter(ILogger logger, bool lowerLogLevel = false, MsLogLevel? maxLogLevel = null) : MicrosoftLogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; @@ -38,7 +39,7 @@ public bool IsEnabled(MsLogLevel logLevel) } public void Log(MsLogLevel logLevel, EventId eventId, - TState state, Exception? exception, Func formatter) + TState state, Exception? exception, Func formatter) { if (lowerLogLevel) { @@ -50,30 +51,40 @@ public void Log(MsLogLevel logLevel, EventId eventId, case MsLogLevel.Critical: case MsLogLevel.Error: if (logger.IsError) + { logger.Error(formatter(state, exception)); + } break; case MsLogLevel.Warning: if (logger.IsWarn) + { logger.Warn(formatter(state, exception)); + } break; case MsLogLevel.Information: if (logger.IsInfo) + { logger.Info(formatter(state, exception)); + } break; case MsLogLevel.Debug: if (logger.IsDebug) + { logger.Debug(formatter(state, exception)); + } break; case MsLogLevel.Trace: if (logger.IsTrace) + { logger.Trace(formatter(state, exception)); + } break; } } private static MsLogLevel LowerLogLevel(MsLogLevel logLevel, MsLogLevel? maxLogLevel) { - // DotNetty outputs Trace level data at Info + // DotNetty outputs Trace level data at Info. MsLogLevel loweredLogLevel = logLevel switch { MsLogLevel.Critical => MsLogLevel.Error, @@ -84,7 +95,7 @@ private static MsLogLevel LowerLogLevel(MsLogLevel logLevel, MsLogLevel? maxLogL _ => logLevel, }; - return loweredLogLevel > maxLogLevel ? maxLogLevel.Value : loweredLogLevel; + return maxLogLevel is not null && loweredLogLevel > maxLogLevel.Value ? maxLogLevel.Value : loweredLogLevel; } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryV5AppTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryV5AppTests.cs index 794d187b5360..1fd8a046b0db 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryV5AppTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryV5AppTests.cs @@ -1,24 +1,28 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Lantern.Discv5.Enr; -using Lantern.Discv5.Enr.Entries; -using Lantern.Discv5.Enr.Identity.V4; -using NSubstitute; +using Autofac; +using Autofac.Features.AttributeFilters; using Nethermind.Config; +using Nethermind.Core.Crypto; using Nethermind.Core.Test.Builders; using Nethermind.Core.Test.Modules; using Nethermind.Crypto; using Nethermind.Db; +using Nethermind.Kademlia; using Nethermind.Logging; using Nethermind.Network.Config; using Nethermind.Network.Discovery.Discv5; -using Nethermind.Serialization.Rlp; +using Nethermind.Network.Enr; +using Nethermind.Stats; using Nethermind.Stats.Model; +using NSubstitute; using NUnit.Framework; +using System; using System.Collections.Generic; using System.Net; -using ENR = Lantern.Discv5.Enr.Enr; +using System.Threading; +using System.Threading.Tasks; namespace Nethermind.Network.Discovery.Test; @@ -27,181 +31,330 @@ namespace Nethermind.Network.Discovery.Test; public class DiscoveryV5AppTests { private MemDb _discoveryDb = null!; - private MemDb _legacyDiscoveryDb = null!; - private IdentityVerifierV4 _identityVerifier = null!; private DiscoveryV5App _discoveryV5App = null!; - - [OneTimeSetUp] - public void OneTimeSetup() => Rlp.RegisterDecoder(typeof(NetworkNode), new NetworkNodeDecoder()); + private readonly List _containers = []; [SetUp] public void Setup() { _discoveryDb = new MemDb(); - _legacyDiscoveryDb = new MemDb(); - _identityVerifier = new IdentityVerifierV4(); _discoveryV5App = CreateDiscoveryV5App(IPAddress.Parse("8.8.8.8")); } - private DiscoveryV5App CreateDiscoveryV5App(IPAddress externalIp) + private DiscoveryV5App CreateDiscoveryV5App(IPAddress externalIp, Action? configureDiscv5Services = null) { NetworkConfig networkConfig = new() { Bootnodes = [], ExternalIp = externalIp.ToString() }; + IProtectedPrivateKey nodeKey = new InsecureProtectedPrivateKey(TestItem.PrivateKeyF); + IEnode enode = new Enode(nodeKey.PublicKey, externalIp, networkConfig.P2PPort, networkConfig.DiscoveryPort); + IIPResolver ipResolver = new FixedIpResolver(networkConfig); + EthereumEcdsa ecdsa = new(0); + ContainerBuilder builder = new(); + builder.RegisterInstance(LimboLogs.Instance).As(); + builder.RegisterInstance(networkConfig).As(); + builder.RegisterInstance(enode).As(); + builder.RegisterInstance(ipResolver).As(); + builder.RegisterInstance(nodeKey).Keyed(IProtectedPrivateKey.NodeKey); + builder.RegisterInstance(ecdsa).As().As(); + builder.RegisterInstance(new CryptoRandom()).As(); + builder.RegisterInstance(new NetworkStorage(_discoveryDb, LimboLogs.Instance)).Keyed(DbNames.DiscoveryV5Nodes); + builder.RegisterInstance(Substitute.For()).As(); + builder.RegisterType().As().WithAttributeFiltering().SingleInstance(); + IContainer container = builder.Build(); + _containers.Add(container); + return new DiscoveryV5App( - new InsecureProtectedPrivateKey(TestItem.PrivateKeyF), - new FixedIpResolver(networkConfig), + container, + nodeKey, + enode, + ipResolver, networkConfig, new DiscoveryConfig { }, - _discoveryDb, - _legacyDiscoveryDb, - LimboLogs.Instance + new ProcessExitSource(CancellationToken.None), + LimboLogs.Instance, + configureDiscv5Services ); } [TearDown] - public void Teardown() + public async Task Teardown() { + if (_discoveryV5App is not null) + { + await _discoveryV5App.DisposeAsync(); + } + for (int i = 0; i < _containers.Count; i++) + { + _containers[i].Dispose(); + } + _containers.Clear(); _discoveryDb.Dispose(); - _legacyDiscoveryDb.Dispose(); } - private ENR CreateTestEnrBytes(Nethermind.Crypto.PrivateKey privateKey, IPAddress? ipAddress = null, int port = 30303) + private static NodeRecord CreateTestEnr(Nethermind.Crypto.PrivateKey privateKey, IPAddress? ipAddress = null, int port = 30303, int? udpPort = null, bool includeTcp = true, bool includeUdp = true, bool includeEth2 = false) => + TestEnrBuilder.BuildSigned( + privateKey, + ipAddress ?? IPAddress.Loopback, + tcpPort: includeTcp ? port : null, + udpPort: includeUdp ? udpPort ?? port : null, + configureExtras: includeEth2 ? static enr => enr.SetEntry(new TestEth2Entry()) : null); + + private static NodeRecord CreateTestIpv6Enr(Nethermind.Crypto.PrivateKey privateKey, IPAddress ipAddress, int udpPort, bool useUdp6 = true) => + TestEnrBuilder.BuildSigned(privateKey, ipAddress, tcpPort: null, udpPort: udpPort, useUdp6: useUdp6); + + private static NodeRecord CreateEnrForAddress(Nethermind.Crypto.PrivateKey privateKey, IPAddress ipAddress) => + ipAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 + ? CreateTestIpv6Enr(privateKey, ipAddress, 30303) + : CreateTestEnr(privateKey, ipAddress); + + [Test] + public void Should_Reject_Private_Ip_Enr() { - IdentitySignerV4 signer = new(privateKey.KeyBytes); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Loopback); - ENR enr = new EnrBuilder() - .WithIdentityScheme(_identityVerifier, signer) - .WithEntry(EnrEntryKey.Id, new EntryId("v4")) - .WithEntry(EnrEntryKey.Ip, new EntryIp(ipAddress ?? IPAddress.Loopback)) - .WithEntry(EnrEntryKey.Secp256K1, new EntrySecp256K1(signer.PublicKey)) - .WithEntry(EnrEntryKey.Tcp, new EntryTcp(port)) - .WithEntry(EnrEntryKey.Udp, new EntryUdp(port)) - .Build(); + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); - return enr; + Assert.That(result, Is.False); + Assert.That(node, Is.Null); } [Test] - public void Should_Migrate_Correctly() + public async Task Should_Accept_Private_Ip_Enr_On_Private_Deployment() { - PrivateKey testPrivateKey1 = TestItem.PrivateKeyA; - ENR enr1 = CreateTestEnrBytes(testPrivateKey1); - _legacyDiscoveryDb[enr1.NodeId] = enr1.EncodeRecord(); + await using DiscoveryV5App privateDiscoveryApp = CreateDiscoveryV5App(IPAddress.Loopback); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Loopback); + + bool result = privateDiscoveryApp.TryGetAcceptableNodeFromEnr(enr, out Node? node); + + Assert.That(result, Is.True); + Assert.That(node, Is.Not.Null); + Assert.That(node!.Host, Is.EqualTo(IPAddress.Loopback.ToString())); + } - PrivateKey testPrivateKey2 = TestItem.PrivateKeyB; - ENR enr2 = CreateTestEnrBytes(testPrivateKey2); - _legacyDiscoveryDb[enr2.NodeId] = enr2.EncodeRecord(); + [TestCase("0.1.2.3")] + [TestCase("192.0.0.1")] + [TestCase("192.0.2.1")] + [TestCase("192.31.196.1")] + [TestCase("192.52.193.1")] + [TestCase("198.18.0.1")] + [TestCase("192.175.48.1")] + [TestCase("198.51.100.1")] + [TestCase("203.0.113.1")] + [TestCase("240.0.0.1")] + [TestCase("::ffff:224.0.0.1")] + [TestCase("64:ff9b::1")] + [TestCase("100::1")] + [TestCase("2001:db8::1")] + [TestCase("2002::1")] + [TestCase("3fff::1")] + public void Should_Reject_Special_Use_Ip_Enr(string ip) + { + NodeRecord enr = CreateEnrForAddress(TestItem.PrivateKeyA, IPAddress.Parse(ip)); - List loadedEnrs = _discoveryV5App.LoadStoredEnrs(); + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); + + Assert.That(result, Is.False); + Assert.That(node, Is.Null); + } + + [TestCase("192.0.2.1")] + [TestCase("2001:db8::1")] + public async Task Should_Reject_Special_Use_Ip_Enr_On_Private_Deployment(string ip) + { + await using DiscoveryV5App privateDiscoveryApp = CreateDiscoveryV5App(IPAddress.Loopback); + NodeRecord enr = CreateEnrForAddress(TestItem.PrivateKeyA, IPAddress.Parse(ip)); + + bool result = privateDiscoveryApp.TryGetAcceptableNodeFromEnr(enr, out Node? node); + + Assert.That(result, Is.False); + Assert.That(node, Is.Null); + } + + [Test] + public void Should_Accept_Public_Ip_Enr() + { + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8")); + + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); + + Assert.That(result, Is.True); + Assert.That(node, Is.Not.Null); + Assert.That(node!.Host, Is.EqualTo("8.8.8.8")); + } + + [Test] + public void Should_Reject_Consensus_Only_Enr() + { + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), includeEth2: true); + NodeRecord decoded = NodeRecord.FromEnrString(enr.EnrString); + + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(decoded, out Node? node); using (Assert.EnterMultipleScope()) { - Assert.That(loadedEnrs.Count, Is.EqualTo(2), "Should get all records"); - Assert.That(_legacyDiscoveryDb.Count, Is.EqualTo(0), "Legacy DB should be empty"); - Assert.That(_discoveryDb.Count, Is.EqualTo(2), "DB should contain all items migrated"); + Assert.That(decoded.HasEntry(EnrContentKey.Eth2), Is.True); + Assert.That(result, Is.False); + Assert.That(node, Is.Null); } } [Test] - public void Should_Stop_Migration_From_V4_DB() + public async Task AddNodeToDiscovery_ShouldSkipNodeWithoutEnr() { - NetworkNode enode1 = new(TestItem.PublicKeyA, IPAddress.Loopback.ToString(), 1, 1); - _legacyDiscoveryDb[enode1.NodeId.Bytes] = Rlp.Encode(enode1).Bytes; - - NetworkNode enode2 = new(TestItem.PublicKeyB, IPAddress.Loopback.ToString(), 1, 1); - _legacyDiscoveryDb[enode2.NodeId.Bytes] = Rlp.Encode(enode2).Bytes; + IKademlia kademlia = Substitute.For>(); + DiscoveryV5App discoveryV5App = CreateDiscoveryV5App( + IPAddress.Parse("8.8.8.8"), + builder => builder.RegisterInstance(kademlia).As>()); - List loadedEnrs = _discoveryV5App.LoadStoredEnrs(); + try + { + discoveryV5App.AddNodeToDiscovery(new Node(TestItem.PublicKeyA, "8.8.8.8", 30303)); - using (Assert.EnterMultipleScope()) + kademlia.DidNotReceive().AddOrRefresh(Arg.Any()); + } + finally { - Assert.That(loadedEnrs.Count, Is.EqualTo(0), "Should not load any nodes if legacy DB contains enodes"); - Assert.That(_legacyDiscoveryDb.Count, Is.EqualTo(2), "Legacy DB should not be changed"); - Assert.That(_discoveryDb.Count, Is.EqualTo(0), "DB should not load any records"); + await discoveryV5App.DisposeAsync(); } } [Test] - public void Should_Reject_Private_Ip_Enr() + public async Task AddNodeToDiscovery_ShouldAddValidatedEnrNode() { - ENR enr = CreateTestEnrBytes(TestItem.PrivateKeyA, IPAddress.Loopback); + IKademlia kademlia = Substitute.For>(); + DiscoveryV5App discoveryV5App = CreateDiscoveryV5App( + IPAddress.Parse("8.8.8.8"), + builder => builder.RegisterInstance(kademlia).As>()); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), udpPort: 30304); + Node node = new(TestItem.PrivateKeyA.PublicKey, "1.1.1.1", 30303) + { + Enr = enr.EnrString + }; - bool result = _discoveryV5App.TryGetNodeFromEnr(enr, out Node? node); + try + { + discoveryV5App.AddNodeToDiscovery(node); - Assert.That(result, Is.False); - Assert.That(node, Is.Null); + kademlia.Received(1).AddOrRefresh(Arg.Is(added => + added.Id.Equals(TestItem.PrivateKeyA.PublicKey) && + added.Host == "8.8.8.8" && + added.Port == 30304 && + added.Enr == enr.EnrString)); + } + finally + { + await discoveryV5App.DisposeAsync(); + } } [Test] - public void Should_Accept_Private_Ip_Enr_On_Private_Deployment() + public async Task AddNodeToDiscovery_ShouldSkipMismatchedEnr() { - DiscoveryV5App privateDiscoveryApp = CreateDiscoveryV5App(IPAddress.Loopback); - ENR enr = CreateTestEnrBytes(TestItem.PrivateKeyA, IPAddress.Loopback); + IKademlia kademlia = Substitute.For>(); + DiscoveryV5App discoveryV5App = CreateDiscoveryV5App( + IPAddress.Parse("8.8.8.8"), + builder => builder.RegisterInstance(kademlia).As>()); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8")); + Node node = new(TestItem.PrivateKeyB.PublicKey, "8.8.8.8", 30303) + { + Enr = enr.EnrString + }; - bool result = privateDiscoveryApp.TryGetNodeFromEnr(enr, out Node? node); + try + { + discoveryV5App.AddNodeToDiscovery(node); - Assert.That(result, Is.True); - Assert.That(node, Is.Not.Null); - Assert.That(node!.Host, Is.EqualTo(IPAddress.Loopback.ToString())); + kademlia.DidNotReceive().AddOrRefresh(Arg.Any()); + } + finally + { + await discoveryV5App.DisposeAsync(); + } } [Test] - public void Should_Accept_Public_Ip_Enr() + public void Should_Use_Udp_Port_From_Enr() { - ENR enr = CreateTestEnrBytes(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8")); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), port: 30303, udpPort: 30304); - bool result = _discoveryV5App.TryGetNodeFromEnr(enr, out Node? node); + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); Assert.That(result, Is.True); Assert.That(node, Is.Not.Null); - Assert.That(node!.Host, Is.EqualTo("8.8.8.8")); + Assert.That(node!.Port, Is.EqualTo(30304)); } [Test] - public void TryEnqueueNewEnr_Should_Deduplicate() + public void Should_Reject_Tcp_Only_Enr() + { + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), includeTcp: true, includeUdp: false); + + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); + + Assert.That(result, Is.False); + Assert.That(node, Is.Null); + } + + [TestCase(true)] + [TestCase(false)] + public void Should_Accept_Ipv6_Enr(bool useUdp6) { - Queue queue = new(); - HashSet seenNodes = []; - ENR enr = CreateTestEnrBytes(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8")); + NodeRecord enr = CreateTestIpv6Enr(TestItem.PrivateKeyA, IPAddress.Parse("2001:4860:4860::8888"), 9001, useUdp6); + + bool result = _discoveryV5App.TryGetAcceptableNodeFromEnr(enr, out Node? node); - Assert.That(DiscoveryV5App.TryEnqueueNewEnr(queue, seenNodes, enr), Is.True); - Assert.That(DiscoveryV5App.TryEnqueueNewEnr(queue, seenNodes, enr), Is.False); - Assert.That(queue.Count, Is.EqualTo(1)); + Assert.That(result, Is.True); + Assert.That(node, Is.Not.Null); + Assert.That(node!.Host, Is.EqualTo("2001:4860:4860::8888")); + Assert.That(node.Port, Is.EqualTo(9001)); } [Test] - public void TryEnqueueNewEnr_Should_Respect_Tracked_Cap() + public void Should_Use_Udp_Port_From_Configured_Enr_Bootnode() { - Queue queue = new(); - HashSet seenNodes = []; - for (int i = 0; i < DiscoveryV5App.MaxTrackedEnrsPerWalk; i++) + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), udpPort: 9001, includeTcp: false); + NetworkConfig networkConfig = new() { - seenNodes.Add(Substitute.For()); - } + Bootnodes = [new NetworkNode(enr.EnrString)] + }; + DiscoveryConfig discoveryConfig = new() + { + UseDefaultDiscv5Bootnodes = false + }; - ENR candidate = CreateTestEnrBytes(TestItem.PrivateKeyB, IPAddress.Parse("1.1.1.1"), port: 30304); + List bootNodes = _discoveryV5App.CreateBootNodes(networkConfig, discoveryConfig); - Assert.That(DiscoveryV5App.TryEnqueueNewEnr(queue, seenNodes, candidate), Is.False); - Assert.That(queue.Count, Is.EqualTo(0)); + using (Assert.EnterMultipleScope()) + { + Assert.That(bootNodes, Has.Count.EqualTo(1)); + Assert.That(bootNodes[0].Port, Is.EqualTo(9001)); + Assert.That(bootNodes[0].Enr, Is.EqualTo(enr.EnrString)); + } } [Test] - public void TryEnqueueNewEnr_Should_Respect_Pending_Cap() + public void Should_Use_Discovery_Port_From_Configured_Enode_Bootnode() { - Queue queue = new(); - ENR existing = CreateTestEnrBytes(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8")); - for (int i = 0; i < DiscoveryV5App.MaxPendingEnrsPerWalk; i++) + Enode enode = new(TestItem.PrivateKeyA.PublicKey, IPAddress.Parse("8.8.8.8"), 30303, discoveryPort: 9001); + NetworkConfig networkConfig = new() { - queue.Enqueue(existing); - } + Bootnodes = [new NetworkNode(enode)] + }; + DiscoveryConfig discoveryConfig = new() + { + UseDefaultDiscv5Bootnodes = false + }; - HashSet seenNodes = []; - ENR candidate = CreateTestEnrBytes(TestItem.PrivateKeyB, IPAddress.Parse("1.1.1.1"), port: 30304); + List bootNodes = _discoveryV5App.CreateBootNodes(networkConfig, discoveryConfig); - Assert.That(DiscoveryV5App.TryEnqueueNewEnr(queue, seenNodes, candidate), Is.False); - Assert.That(queue.Count, Is.EqualTo(DiscoveryV5App.MaxPendingEnrsPerWalk)); + using (Assert.EnterMultipleScope()) + { + Assert.That(bootNodes, Has.Count.EqualTo(1)); + Assert.That(bootNodes[0].Port, Is.EqualTo(9001)); + Assert.That(bootNodes[0].Host, Is.EqualTo("8.8.8.8")); + } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscV4KademliaModuleTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscV4KademliaModuleTests.cs deleted file mode 100644 index 11170fe76f65..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscV4KademliaModuleTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Net; -using Autofac; -using Nethermind.Config; -using Nethermind.Core; -using Nethermind.Core.Test.Builders; -using Nethermind.Network.Config; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats.Model; -using NUnit.Framework; - -namespace Nethermind.Network.Discovery.Test.Discv4; - -[TestFixture] -public class DiscV4KademliaModuleTests -{ - [Test] - public void CurrentNodeId_uses_enode_ip_and_discovery_port() - { - IEnode enode = new Enode(TestItem.PublicKeyA, IPAddress.Parse("10.0.0.5"), 30303); - INetworkConfig networkConfig = new NetworkConfig - { - DiscoveryPort = 30304, - P2PPort = 30303, - }; - - ContainerBuilder containerBuilder = new(); - containerBuilder.RegisterInstance(new DiscoveryConfig()).As(); - containerBuilder.AddModule(new DiscV4KademliaModule(enode, networkConfig, [])); - using IContainer container = containerBuilder.Build(); - - KademliaConfig config = container.Resolve>(); - - Assert.That(config.CurrentNodeId.Id, Is.EqualTo(enode.PublicKey)); - Assert.That(config.CurrentNodeId.Host, Is.EqualTo("10.0.0.5")); - Assert.That(config.CurrentNodeId.Port, Is.EqualTo(30304)); - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryAppTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryAppTests.cs new file mode 100644 index 000000000000..2144931f2a01 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryAppTests.cs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections.Generic; +using System.Net; +using Nethermind.Config; +using Nethermind.Core.Test.Builders; +using Nethermind.Logging; +using Nethermind.Network.Discovery.Discv4; +using Nethermind.Stats.Model; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv4; + +public class DiscoveryAppTests +{ + [Test] + public void Should_use_discovery_port_from_configured_enode_bootnode() + { + Enode enode = new(TestItem.PrivateKeyA.PublicKey, IPAddress.Parse("8.8.8.8"), 30303, discoveryPort: 9001); + + List bootNodes = DiscoveryApp.CreateBootNodes([new NetworkNode(enode)], LimboLogs.Instance.GetClassLogger()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(bootNodes, Has.Count.EqualTo(1)); + Assert.That(bootNodes[0].Port, Is.EqualTo(9001)); + Assert.That(bootNodes[0].Host, Is.EqualTo("8.8.8.8")); + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryMessageSerializerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryMessageSerializerTests.cs similarity index 77% rename from src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryMessageSerializerTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryMessageSerializerTests.cs index bf0672e074d1..6a1687c86021 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryMessageSerializerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryMessageSerializerTests.cs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using System.Net; using System.Linq; using DotNetty.Buffers; @@ -10,7 +11,7 @@ using Nethermind.Core.Test.Builders; using Nethermind.Crypto; using Nethermind.Network.Config; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.Enr; using Nethermind.Network.Test; using Nethermind.Network.Test.Builders; @@ -18,7 +19,7 @@ using Nethermind.Serialization.Rlp; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test; +namespace Nethermind.Network.Discovery.Test.Discv4; [Parallelizable(ParallelScope.Self)] public class DiscoveryMessageSerializerTests @@ -75,7 +76,7 @@ public void PingMessageTest() } [Test] - public void PingMessage_Rejects_Port_Zero() + public void PingMessage_Allows_Endpoint_Port_Zero() { PingMsg message = new(_privateKey.PublicKey, 60 + _timestamper.UnixTime.MillisecondsLong, new IPEndPoint(_farAddress.Address, 0), _nearAddress, @@ -83,7 +84,21 @@ public void PingMessage_Rejects_Port_Zero() { FarAddress = _farAddress }; using DisposableByteBuffer data = _messageSerializationService.ZeroSerialize(message).AsDisposable(); - Assert.Throws(() => _messageSerializationService.Deserialize(data)); + PingMsg deserializedMessage = _messageSerializationService.Deserialize(data); + + Assert.That(deserializedMessage.SourceAddress.Port, Is.Zero); + } + + [Test] + public void PingMessage_UsesUdpPortWhenTcpPortIsZero() + { + string devp2pDiscoveryOnlyPing = + "24fca4f142312eb2c8b850295cf1c7b3dbbcac49c79a4e1dbc84bffde5e3605bd3c81c2d313f3ac8f293ead68f0efc76d78033279923216da1ad5bc0356f0d0e4dab24602f48452ac037d8a7291260c6999fc9f65b85095fb5f6abc4ed1f49020101e104c9847f00000182f2bb80c9847f000001827d6580846a17400086019e6ad1b545"; + + PingMsg ping = _messageSerializationService.Deserialize(Bytes.FromHexString(devp2pDiscoveryOnlyPing)); + + Assert.That(ping.SourceAddress, Is.EqualTo(new IPEndPoint(IPAddress.Loopback, 62139))); + Assert.That(ping.DestinationAddress, Is.EqualTo(new IPEndPoint(IPAddress.Loopback, 32101))); } [Test] @@ -91,7 +106,7 @@ public void PongMessageTest() { using PooledBufferLeakDetector detector = new(_leakDetectionAllocator); PongMsg message = - new(_privateKey.PublicKey, 60 + _timestamper.UnixTime.MillisecondsLong, new byte[] { 1, 2, 3 }) + new(_privateKey.PublicKey, 60 + _timestamper.UnixTime.MillisecondsLong, TestItem.KeccakA.ValueHash256) { FarAddress = _farAddress }; @@ -130,16 +145,20 @@ public void Enr_request_there_and_back() } [Test] - public void Enr_request_contains_hash() + public void Enr_request_hash_does_not_alias_input_buffer() { EnrRequestMsg msg = new(TestItem.PublicKeyA, long.MaxValue); using DisposableByteBuffer serialized = _messageSerializationService.ZeroSerialize(msg).AsDisposable(); - EnrRequestMsg deserialized = _messageSerializationService.Deserialize(serialized); + byte[] packet = serialized.ReadAllBytesAsArray(); + Hash256 expectedHash = new(packet.AsSpan(0, 32)); + Assert.That(expectedHash, Is.EqualTo(new Hash256("0x64c2e38e89cdfca030166b7a271c301dd77cf043172966ab112d97fc3430fa16"))); - Assert.That(deserialized.Hash, Is.Not.Null); - Hash256 hash = new(deserialized.Hash!.Value.Span); + using DisposableByteBuffer input = Unpooled.WrappedBuffer(packet).AsDisposable(); + EnrRequestMsg deserialized = _messageSerializationService.Deserialize(input); + Array.Clear(packet); - Assert.That(hash, Is.EqualTo(new Hash256("0x64c2e38e89cdfca030166b7a271c301dd77cf043172966ab112d97fc3430fa16"))); + Assert.That(deserialized.Hash, Is.Not.Null); + Assert.That(new Hash256(deserialized.Hash!.Value), Is.EqualTo(expectedHash)); } [Test] @@ -261,6 +280,55 @@ public void NeighborsMessageTest() } } + [Test] + public void NeighborsMessage_Drops_Empty_List_Node_Entries() + { + // A misbehaving peer can encode a node entry as an RLP empty list (0xc0); + // such entries must be skipped instead of reaching consumers. + byte[] ip = [192, 168, 1, 2]; + byte[] id = TestItem.PublicKeyA.Bytes; + const int port = 30303; + long expirationTime = 60 + _timestamper.UnixTime.MillisecondsLong; + + int nodeContentLength = Rlp.LengthOf(ip) + 2 * Rlp.LengthOf(port) + Rlp.LengthOf(id); + int nodesContentLength = Rlp.LengthOfSequence(nodeContentLength) + Rlp.OfEmptyList.Bytes.Length; + int contentLength = Rlp.LengthOfSequence(nodesContentLength) + Rlp.LengthOf(expirationTime); + + RlpStream stream = new(Rlp.LengthOfSequence(contentLength)); + stream.StartSequence(contentLength); + stream.StartSequence(nodesContentLength); + stream.StartSequence(nodeContentLength); + stream.Encode(ip); + stream.Encode(port); + stream.Encode(port); + stream.Encode(id); + stream.Encode(Rlp.OfEmptyList); + stream.Encode(expirationTime); + + NeighborsMsg deserialized = _messageSerializationService.Deserialize( + SignAndWrapDiscoveryPacket((byte)MsgType.Neighbors, stream.Data.ToArray()!)); + + Assert.That(deserialized.Nodes, Has.Count.EqualTo(1)); + Assert.That(deserialized.Nodes[0].Id, Is.EqualTo(TestItem.PublicKeyA)); + } + + private byte[] SignAndWrapDiscoveryPacket(byte msgType, byte[] data) + { + // [] + byte[] packet = new byte[32 + 64 + 1 + 1 + data.Length]; + packet[97] = msgType; + data.CopyTo(packet, 98); + + ValueHash256 toSign = ValueKeccak.Compute(packet.AsSpan(97)); + Signature signature = new Ecdsa().Sign(_privateKey, in toSign); + signature.Bytes.CopyTo(packet.AsSpan(32)); + packet[96] = signature.RecoveryId; + + ValueHash256 mdc = ValueKeccak.Compute(packet.AsSpan(32)); + mdc.BytesAsSpan.CopyTo(packet); + return packet; + } + private EnrResponseMsg BuildEnrResponse(CompressedPublicKey enrPublicKey) { NodeRecord nodeRecord = new(); @@ -300,4 +368,23 @@ public void NeighborsMessage_Rejects_Too_Many_Nodes() using DisposableByteBuffer data = _messageSerializationService.ZeroSerialize(message).AsDisposable(); Assert.Throws(() => _messageSerializationService.Deserialize(data)); } + + [Test] + public void PongMessage_Rejects_Oversized_Ping_Mdc() + { + RlpStream stream = new(128); + long expirationTime = 60 + _timestamper.UnixTime.MillisecondsLong; + int addressContentLength = Rlp.LengthOf(new byte[] { 127, 0, 0, 1 }) + Rlp.LengthOf(30303) + Rlp.LengthOf(30303); + int contentLength = Rlp.LengthOfSequence(addressContentLength) + Rlp.LengthOf(new byte[33]) + Rlp.LengthOf(expirationTime); + stream.StartSequence(contentLength); + stream.StartSequence(addressContentLength); + stream.Encode(new byte[] { 127, 0, 0, 1 }); + stream.Encode(30303); + stream.Encode(30303); + stream.Encode(new byte[33]); + stream.Encode(expirationTime); + + Assert.Throws(() => _messageSerializationService.Deserialize( + SignAndWrapDiscoveryPacket((byte)MsgType.Pong, stream.Data.ToArray()!))); + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryPersistenceManagerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryPersistenceManagerTests.cs similarity index 76% rename from src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryPersistenceManagerTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryPersistenceManagerTests.cs index aca5938da2a1..c0df4d8a6104 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryPersistenceManagerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/DiscoveryPersistenceManagerTests.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Net; using System.Threading; using System.Threading.Tasks; using Nethermind.Config; @@ -10,14 +11,16 @@ using Nethermind.Core.Test.Builders; using Nethermind.Db; using Nethermind.Logging; -using Nethermind.Network.Discovery.Discv4; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv4.Kademlia; using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Network.Enr; using Nethermind.Stats; using Nethermind.Stats.Model; using NSubstitute; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test +namespace Nethermind.Network.Discovery.Test.Discv4 { [Parallelizable(ParallelScope.Self)] public class DiscoveryPersistenceManagerTests @@ -28,7 +31,7 @@ public class DiscoveryPersistenceManagerTests private MemDb _discoveryDb = null!; private INetworkStorage _networkStorage = null!; private INodeStatsManager _nodeStatsManager = null!; - private IKademliaDiscv4Adapter _discv4Adapter = null!; + private IKademliaAdapter _discv4Adapter = null!; private IDiscoveryConfig _discoveryConfig = null!; private ILogManager _logManager = null!; private IKademlia _kademlia = null!; @@ -37,12 +40,10 @@ public class DiscoveryPersistenceManagerTests [SetUp] public void Setup() { - NetworkNodeDecoder.Init(); - _discoveryDb = new MemDb(); _networkStorage = new NetworkStorage(_discoveryDb, LimboLogs.Instance); _nodeStatsManager = Substitute.For(); - _discv4Adapter = Substitute.For(); + _discv4Adapter = Substitute.For(); _discoveryConfig = new DiscoveryConfig() { DiscoveryPersistenceInterval = 100, @@ -68,7 +69,7 @@ public async Task Teardown() _discoveryConfig, _logManager); - private static Task PingReceived(IKademliaDiscv4Adapter adapter, NetworkNode node, int times = 1) => + private static Task PingReceived(IKademliaAdapter adapter, NetworkNode node, int times = 1) => adapter.Received(times).Ping( Arg.Is(n => n.Id.Equals(node.NodeId)), Arg.Any()); @@ -176,5 +177,46 @@ public async Task RunDiscoveryPersistenceCommit_Should_Update_Nodes_In_Storage() Assert.That(_discoveryDb.Count, Is.EqualTo(nodes.Length)); } + + [Test] + public async Task RunDiscoveryPersistenceCommit_Should_Preserve_Enr_In_Common_Storage() + { + NodeRecord enr = TestEnrBuilder.BuildSigned(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), tcpPort: 30303, udpPort: 30304); + Node node = new(TestItem.PrivateKeyA.PublicKey, "8.8.8.8", 30304) + { + Enr = enr.EnrString + }; + + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + + _kademlia.IterateNodes().Returns([node]); + + _ = _persistenceManager.RunDiscoveryPersistenceCommit(cts.Token); + + while (_discoveryDb.Count == 0) + { + cts.Token.ThrowIfCancellationRequested(); + await Task.Delay(10, cts.Token); + } + + await cts.CancelAsync(); + + NetworkStorage reloadedStorage = new(_discoveryDb, LimboLogs.Instance); + NetworkNode[] persistedNodes = reloadedStorage.GetPersistedNodes(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(persistedNodes, Has.Length.EqualTo(1)); + NetworkNode persistedNode = persistedNodes[0]; + NodeRecord? persistedEnr = persistedNode.Enr; + Assert.That(persistedNode.IsEnr, Is.True); + Assert.That(persistedEnr, Is.Not.Null); + Assert.That(persistedEnr!.EnrString, Is.EqualTo(enr.EnrString)); + Assert.That(persistedNode.NodeId, Is.EqualTo(TestItem.PrivateKeyA.PublicKey)); + Assert.That(persistedNode.Host, Is.EqualTo("8.8.8.8")); + Assert.That(persistedNode.Port, Is.EqualTo(30304)); + } + } + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/EIP8DiscoveryTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/EIP8DiscoveryTests.cs similarity index 98% rename from src/Nethermind/Nethermind.Network.Discovery.Test/EIP8DiscoveryTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/EIP8DiscoveryTests.cs index c3f93ca82ced..31a3be33bc11 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/EIP8DiscoveryTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/EIP8DiscoveryTests.cs @@ -4,11 +4,11 @@ using Nethermind.Core.Extensions; using Nethermind.Core.Test.Builders; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.Test.Builders; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test +namespace Nethermind.Network.Discovery.Test.Discv4 { [Parallelizable(ParallelScope.Self)] [TestFixture] diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/IteratorNodeLookupTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/IteratorNodeLookupTests.cs deleted file mode 100644 index 57c5b89cd611..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/IteratorNodeLookupTests.cs +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Nethermind.Core; -using Nethermind.Core.Crypto; -using Nethermind.Core.Test.Builders; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats.Model; -using NSubstitute; -using NUnit.Framework; - -namespace Nethermind.Network.Discovery.Test.Discv4 -{ - [Parallelizable(ParallelScope.Self)] - [TestFixture] - public class IteratorNodeLookupTests - { - private static readonly Node InitialNode = new(TestItem.PublicKeyC, "192.168.1.3", 30303); - private static readonly Node NeighbourNode = new(TestItem.PublicKeyD, "192.168.1.4", 30303); - - private IRoutingTable _routingTable = null!; - private IteratorNodeLookup _lookup = null!; - private IKademliaMessageSender _msgSender = null!; - private Node _currentNode = null!; - private PublicKey _targetKey = null!; - - [SetUp] - public void Setup() - { - _currentNode = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - _targetKey = TestItem.PublicKeyB; - - _routingTable = Substitute.For>(); - KademliaConfig kademliaConfig = new() { CurrentNodeId = _currentNode }; - _msgSender = Substitute.For>(); - ILogManager logManager = Substitute.For(); - - _lookup = new IteratorNodeLookup( - _routingTable, - kademliaConfig, - _msgSender, - new PublicKeyKeyOperator(), - new ManualTimestamper(new DateTime(2025, 5, 13, 21, 0, 0, DateTimeKind.Utc)), - logManager); - } - - private void RoutingTableReturns(params Node[] nodes) => - _routingTable.GetKNearestNeighbour(Arg.Any(), Arg.Any()) - .Returns(nodes); - - private void FindNeighboursReturns(Node from, params Node[] result) => - _msgSender.FindNeighbours(from, _targetKey, Arg.Any()) - .Returns(result); - - private void FindNeighboursThrows(Node from, Exception exception) => - _msgSender.FindNeighbours(from, _targetKey, Arg.Any()) - .Returns(Task.FromException(exception)); - - private Task AssertFindNeighboursCalledOnce(Node node) => - _msgSender.Received(1).FindNeighbours( - Arg.Is(n => n == node), - Arg.Is(k => k == _targetKey), - Arg.Any()); - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_return_nodes_from_routing_table(CancellationToken token) - { - Node[] expectedNodes = [InitialNode, NeighbourNode]; - RoutingTableReturns(expectedNodes); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(token); - - Assert.That(result, Is.EquivalentTo(expectedNodes)); - _routingTable.Received(1).GetKNearestNeighbour( - Arg.Is(h => h == _targetKey.Hash), - Arg.Any()); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_query_nodes_and_return_neighbours(CancellationToken token) - { - RoutingTableReturns(InitialNode); - FindNeighboursReturns(InitialNode, NeighbourNode); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(token); - - Assert.That(result, Is.EquivalentTo(new[] { InitialNode, NeighbourNode })); - await AssertFindNeighboursCalledOnce(InitialNode); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_not_query_self_node(CancellationToken token) - { - RoutingTableReturns(_currentNode); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(token); - - Assert.That(result, Is.EquivalentTo(new[] { _currentNode })); - - await _msgSender.DidNotReceive().FindNeighbours( - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_handle_empty_neighbour_response(CancellationToken token) - { - RoutingTableReturns(InitialNode); - FindNeighboursReturns(InitialNode); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(token); - - Assert.That(result, Is.EquivalentTo(new[] { InitialNode })); - await AssertFindNeighboursCalledOnce(InitialNode); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_handle_exception_in_find_neighbours(CancellationToken token) - { - RoutingTableReturns(InitialNode); - FindNeighboursThrows(InitialNode, new Exception("Test exception")); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(token); - - Assert.That(result, Is.EquivalentTo(new[] { InitialNode })); - await AssertFindNeighboursCalledOnce(InitialNode); - } - - [Test] - [CancelAfter(10000)] - public void Lookup_should_respect_cancellation_token(CancellationToken token) - { - RoutingTableReturns(InitialNode); - - using CancellationTokenSource cts = new(); - cts.Cancel(); - - Assert.ThrowsAsync(async () => await _lookup.Lookup(_targetKey, cts.Token).ToListAsync()); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_not_query_same_node_twice(CancellationToken token) - { - RoutingTableReturns(InitialNode); - FindNeighboursReturns(InitialNode, NeighbourNode); - FindNeighboursReturns(NeighbourNode, InitialNode); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(); - - Assert.That(result, Is.EquivalentTo(new[] { InitialNode, NeighbourNode })); - await AssertFindNeighboursCalledOnce(InitialNode); - await AssertFindNeighboursCalledOnce(NeighbourNode); - } - - [Test] - [CancelAfter(10000)] - public async Task Lookup_should_not_return_duplicate_nodes(CancellationToken token) - { - RoutingTableReturns(InitialNode); - FindNeighboursReturns(InitialNode, NeighbourNode); - FindNeighboursReturns(NeighbourNode, InitialNode, NeighbourNode); - - List result = await _lookup.Lookup(_targetKey, token).ToListAsync(); - - Assert.That(result, Is.EquivalentTo(new[] { InitialNode, NeighbourNode })); - } - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NeighbourMsgHandlerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/Handlers/NeighbourMsgHandlerTests.cs similarity index 68% rename from src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NeighbourMsgHandlerTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/Handlers/NeighbourMsgHandlerTests.cs index ffc3ad57160c..0ef611cfe384 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NeighbourMsgHandlerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/Handlers/NeighbourMsgHandlerTests.cs @@ -2,16 +2,17 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using Nethermind.Core.Crypto; using Nethermind.Core.Test.Builders; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Stats.Model; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test.Discv4 +namespace Nethermind.Network.Discovery.Test.Discv4.Kademlia.Handlers { [Parallelizable(ParallelScope.Self)] [TestFixture] @@ -30,13 +31,17 @@ public void Setup() _expirationTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 60; // 60 seconds in the future } - [Test] - public async Task When_TotalNodesLessThanK_ThenDontFinish_UntilTimeout() + [TestCaseSource(nameof(TimeoutCases))] + public async Task When_TotalNodesDoNotCompleteImmediately_ThenCompleteAfterTimeout(int nodeCount, bool[] expectedHandleResults) { - ArraySegment nodes = CreateNodes(5); + ArraySegment nodes = CreateNodes(nodeCount); NeighborsMsg msg = new(_farAddress, _expirationTime, nodes); - Assert.That(_handler.Handle(msg), Is.True); + for (int i = 0; i < expectedHandleResults.Length; i++) + { + Assert.That(_handler.Handle(msg), Is.EqualTo(expectedHandleResults[i])); + } + Assert.That(_handler.TaskCompletionSource.Task.IsCompleted, Is.False); await _handler.TaskCompletionSource.Task; @@ -54,16 +59,10 @@ public void When_TotalNodesLessEqualToK_ThenFinishImmediately() Assert.That(_handler.TaskCompletionSource.Task.IsCompleted, Is.True); } - [Test] - public async Task When_TotalNodesDoesNotAddUp_DontTakeMessage() + private static IEnumerable TimeoutCases() { - ArraySegment nodes = CreateNodes(10); - NeighborsMsg msg = new(_farAddress, _expirationTime, nodes); - - Assert.That(_handler.Handle(msg), Is.True); - Assert.That(_handler.Handle(msg), Is.False); - Assert.That(_handler.TaskCompletionSource.Task.IsCompleted, Is.False); - await _handler.TaskCompletionSource.Task; + yield return new TestCaseData(5, new[] { true }).SetName("FewerThanK"); + yield return new TestCaseData(10, new[] { true, false }).SetName("SecondMessageWouldOverflowK"); } private ArraySegment CreateNodes(int count, int startIndex = 0) diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaDiscv4AdapterTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/KademliaAdapterTests.cs similarity index 75% rename from src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaDiscv4AdapterTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/KademliaAdapterTests.cs index 9d7160bbd8aa..c3430afc60c8 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaDiscv4AdapterTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/KademliaAdapterTests.cs @@ -6,29 +6,29 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using DotNetty.Buffers; using Nethermind.Config; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Core.Test.Builders; -using Nethermind.Crypto; +using Nethermind.Kademlia; using Nethermind.Logging; using Nethermind.Network.Config; using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Kademlia; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.Enr; +using Nethermind.Network.Test; using Nethermind.Network.Test.Builders; using Nethermind.Stats; using Nethermind.Stats.Model; using NSubstitute; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test.Discv4 +namespace Nethermind.Network.Discovery.Test.Discv4.Kademlia { [Parallelizable(ParallelScope.Self)] [TestFixture] - public class KademliaDiscv4AdapterTests + public class KademliaAdapterTests { public enum NoResponseRequest { @@ -37,7 +37,7 @@ public enum NoResponseRequest SendEnrRequest } - private IKademliaDiscv4Adapter _adapter = null!; + private IKademliaAdapter _adapter = null!; private IKademlia _kademliaMessageReceiver = null!; private INodeHealthTracker _nodeHealthTracker = null!; @@ -60,16 +60,24 @@ private void ConfigureBondCallback() => .Do(ci => { PingMsg sent = (PingMsg)ci[0]!; - IByteBuffer buffer = _receiverSerializationManager.ZeroSerialize(sent); + using DisposableByteBuffer buffer = _receiverSerializationManager.ZeroSerialize(sent).AsDisposable(); PingMsg msg = _receiverSerializationManager.Deserialize(buffer); PongMsg pong = new( msg.FarPublicKey!, _timestamper.UnixTime.SecondsLong + 1, - sent.Mdc!); + sent.Mdc!.Value); pong.FarAddress = _receiver.Address; Task.Run(() => _adapter.OnIncomingMsg(pong)); }); + private async Task BondReceiver(CancellationToken token) + { + ConfigureBondCallback(); + await _adapter.Ping(_receiver, token); + _msgSender.ClearReceivedCalls(); + _nodeHealthTracker.ClearReceivedCalls(); + } + [SetUp] public void Setup() { @@ -83,7 +91,11 @@ public void Setup() _networkConfig.MaxActivePeers.Returns(25); _kademliaConfig = new KademliaConfig { CurrentNodeId = _testNode }; - _selfNodeRecord = CreateNodeRecord(); + _selfNodeRecord = TestEnrBuilder.BuildSigned( + TestItem.PrivateKeyA, + IPAddress.Parse("192.168.1.1"), + tcpPort: _networkConfig.P2PPort, + udpPort: _networkConfig.DiscoveryPort); _logManager = LimboLogs.Instance; _timestamper = Substitute.For(); @@ -103,7 +115,7 @@ public void Setup() _nodeStatsManager = Substitute.For(); _nodeStatsManager.GetOrAdd(Arg.Any()).Returns(Substitute.For()); - _adapter = new KademliaDiscv4Adapter( + _adapter = new KademliaAdapter( new Lazy>(() => _kademliaMessageReceiver), new Lazy>(() => _nodeHealthTracker), new DiscoveryConfig @@ -134,31 +146,12 @@ public async Task GetSession_should_return_single_session_for_concurrent_calls() _nodeStatsManager.Received(1).GetOrAdd(Arg.Is(node => node.Id == _receiver.Id)); } - private NodeRecord CreateNodeRecord() - { - NodeRecord selfNodeRecord = new(); - selfNodeRecord.SetEntry(IdEntry.Instance); - selfNodeRecord.SetEntry(new IpEntry(IPAddress.Parse("192.168.1.1"))); - selfNodeRecord.SetEntry(new TcpEntry(_networkConfig.P2PPort)); - selfNodeRecord.SetEntry(new UdpEntry(_networkConfig.DiscoveryPort)); - selfNodeRecord.SetEntry(new SecP256k1Entry(TestItem.PrivateKeyA.CompressedPublicKey)); - selfNodeRecord.EnrSequence = 1; - NodeRecordSigner enrSigner = new(new EthereumEcdsa(BlockchainIds.Mainnet), TestItem.PrivateKeyA); - enrSigner.Sign(selfNodeRecord); - if (!enrSigner.Verify(selfNodeRecord)) - { - throw new NetworkingException("Self ENR initialization failed", NetworkExceptionType.Discovery); - } - - return selfNodeRecord; - } - [TearDown] public async Task TearDown() => await _adapter.DisposeAsync(); private T AddReceiverFarAddress(T msg) where T : DiscoveryMsg { - IByteBuffer buffer = _receiverSerializationManager.ZeroSerialize(msg); + using DisposableByteBuffer buffer = _receiverSerializationManager.ZeroSerialize(msg).AsDisposable(); IPEndPoint? farAddress = msg.FarAddress; msg = _receiverSerializationManager.Deserialize(buffer); msg.FarAddress = farAddress; @@ -174,6 +167,15 @@ private async Task HasResponse(NoResponseRequest request, CancellationToke _ => throw new ArgumentOutOfRangeException(nameof(request), request, null) }; + private DiscoveryMsg CreateUnsolicitedResponse(MsgType msgType) => + msgType switch + { + MsgType.Pong => AddReceiverFarAddress(new PongMsg(_receiver.Address, _timestamper.UnixTime.SecondsLong + 1, TestItem.KeccakA.ValueHash256)), + MsgType.Neighbors => AddReceiverFarAddress(new NeighborsMsg(_receiver.Address, _timestamper.UnixTime.SecondsLong + 1, Array.Empty())), + MsgType.EnrResponse => AddReceiverFarAddress(new EnrResponseMsg(_receiver.Address, _selfNodeRecord, TestItem.KeccakA)), + _ => throw new ArgumentOutOfRangeException(nameof(msgType), msgType, null) + }; + [Test] [CancelAfter(10000)] public async Task Ping_should_send_ping_and_receive_pong(CancellationToken token) @@ -221,7 +223,7 @@ public async Task SendEnrRequest_should_ping_then_enr_request_and_return_respons { ConfigureBondCallback(); - byte[] requestHash = TestItem.KeccakA.BytesToArray(); + ValueHash256 requestHash = TestItem.KeccakA.ValueHash256; _msgSender .When(x => x.SendMsg(Arg.Any())) .Do(ci => @@ -249,7 +251,7 @@ public async Task SendEnrRequest_should_reject_unsolicited_response_with_wrong_k .Do(ci => { EnrRequestMsg sent = (EnrRequestMsg)ci[0]!; - sent.Hash = TestItem.KeccakA.BytesToArray(); + sent.Hash = TestItem.KeccakA.ValueHash256; EnrResponseMsg response = AddReceiverFarAddress(new EnrResponseMsg(_receiver.Address, _selfNodeRecord, TestItem.KeccakB)); Task.Run(() => _adapter.OnIncomingMsg(response)); }); @@ -287,6 +289,20 @@ public async Task Timed_out_response_handler_should_not_consume_later_unsolicite _nodeHealthTracker.DidNotReceive().OnIncomingMessageFrom(Arg.Is(n => n.Id.Equals(_receiver.Id))); } + [TestCase(MsgType.Pong)] + [TestCase(MsgType.Neighbors)] + [TestCase(MsgType.EnrResponse)] + [CancelAfter(10000)] + public async Task OnIncomingMsg_unsolicited_response_should_not_create_session_stats(MsgType msgType) + { + DiscoveryMsg response = CreateUnsolicitedResponse(msgType); + + await _adapter.OnIncomingMsg(response); + + _nodeStatsManager.DidNotReceive().GetOrAdd(Arg.Any()); + _nodeHealthTracker.DidNotReceive().OnIncomingMessageFrom(Arg.Any()); + } + [TestCase(NoResponseRequest.Ping)] [TestCase(NoResponseRequest.FindNeighbours)] [TestCase(NoResponseRequest.SendEnrRequest)] @@ -342,7 +358,7 @@ public async Task Failed_send_should_remove_response_handler(CancellationToken t _nodeHealthTracker.ClearReceivedCalls(); - PongMsg response = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 1, sent!.Mdc!); + PongMsg response = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 1, sent!.Mdc!.Value); response = AddReceiverFarAddress(response); await _adapter.OnIncomingMsg(response); @@ -361,20 +377,17 @@ public async Task OnIncomingMsg_ping_should_respond_with_pong(CancellationToken await _adapter.OnIncomingMsg(pingMsg); - await Task.Delay(100); - + ValueHash256 expectedPingMdc = pingMsg.Mdc!.Value; await _msgSender.Received(1).SendMsg(Arg.Is(m => m.FarAddress!.Equals(_receiver.Address) && - m.PingMdc!.SequenceEqual(pingMsg.Mdc!))); + m.PingMdc == expectedPingMdc)); } [Test] [CancelAfter(10000)] public async Task OnIncomingMsg_find_node_should_respond_with_neighbors(CancellationToken token) { - ConfigureBondCallback(); - Assert.That(await _adapter.Ping(_receiver, token), Is.True); - _msgSender.ClearReceivedCalls(); + await BondReceiver(token); FindNodeMsg findNodeMsg = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 20, _testPublicKey.Bytes); findNodeMsg = AddReceiverFarAddress(findNodeMsg); @@ -387,8 +400,6 @@ public async Task OnIncomingMsg_find_node_should_respond_with_neighbors(Cancella await _adapter.OnIncomingMsg(findNodeMsg); - await Task.Delay(100); - _kademliaMessageReceiver.GetKNeighbour( Arg.Is(pk => pk.Bytes!.SequenceEqual(_testPublicKey.Bytes!)), Arg.Is(n => n.Id == _receiver.Id)); @@ -402,24 +413,66 @@ await _msgSender.Received(1).SendMsg(Arg.Is(m => m.Nodes.Count == 8)); } + [Test] + [CancelAfter(10000)] + public async Task OnIncomingMsg_find_node_from_unbonded_peer_should_not_update_node_health(CancellationToken token) + { + FindNodeMsg findNodeMsg = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 20, _testPublicKey.Bytes); + findNodeMsg = AddReceiverFarAddress(findNodeMsg); + + await _adapter.OnIncomingMsg(findNodeMsg); + + _nodeHealthTracker.DidNotReceive().OnIncomingMessageFrom(Arg.Is(n => n.Id == _receiver.Id)); + _kademliaMessageReceiver.DidNotReceive().GetKNeighbour(Arg.Any(), Arg.Any()); + await _msgSender.DidNotReceive().SendMsg(Arg.Any()); + } + + [Test] + [CancelAfter(10000)] + public async Task OnIncomingMsg_find_node_from_different_endpoint_should_not_respond(CancellationToken token) + { + await BondReceiver(token); + + IPEndPoint differentEndpoint = new(IPAddress.Parse("192.168.1.3"), _receiver.Address.Port); + FindNodeMsg findNodeMsg = new(differentEndpoint, _timestamper.UnixTime.SecondsLong + 20, _testPublicKey.Bytes); + findNodeMsg = AddReceiverFarAddress(findNodeMsg); + + await _adapter.OnIncomingMsg(findNodeMsg); + + _nodeHealthTracker.DidNotReceive().OnIncomingMessageFrom(Arg.Is(n => n.Id == _receiver.Id)); + _kademliaMessageReceiver.DidNotReceive().GetKNeighbour(Arg.Any(), Arg.Any()); + await _msgSender.DidNotReceive().SendMsg(Arg.Any()); + } + [Test] [CancelAfter(10000)] public async Task OnIncomingMsg_enr_request_should_respond_with_enr_response(CancellationToken token) { - ConfigureBondCallback(); - Assert.That(await _adapter.Ping(_receiver, token), Is.True); - _msgSender.ClearReceivedCalls(); + await BondReceiver(token); EnrRequestMsg enrRequestMsg = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 20); enrRequestMsg = AddReceiverFarAddress(enrRequestMsg); + Hash256 expectedRequestHash = new(enrRequestMsg.Hash!.Value); await _adapter.OnIncomingMsg(enrRequestMsg); - Task.Delay(100).Wait(); - await _msgSender.Received(1).SendMsg(Arg.Is(m => m.FarAddress!.Equals(_receiver.Address) && + m.RequestKeccak.Equals(expectedRequestHash) && m.NodeRecord.Equals(_selfNodeRecord))); } + + [Test] + [CancelAfter(10000)] + public async Task OnIncomingMsg_enr_request_from_unbonded_peer_should_not_update_node_health(CancellationToken token) + { + EnrRequestMsg enrRequestMsg = new(_receiver.Address, _timestamper.UnixTime.SecondsLong + 20); + enrRequestMsg = AddReceiverFarAddress(enrRequestMsg); + + await _adapter.OnIncomingMsg(enrRequestMsg); + + _nodeHealthTracker.DidNotReceive().OnIncomingMessageFrom(Arg.Is(n => n.Id == _receiver.Id)); + await _msgSender.DidNotReceive().SendMsg(Arg.Any()); + } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/NodeSourceTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/NodeSourceTests.cs new file mode 100644 index 000000000000..703420c2df4c --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/Kademlia/NodeSourceTests.cs @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.Extensions; +using Nethermind.Core.Test.Builders; +using Nethermind.Core.Utils; +using Nethermind.Logging; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv4; +using Nethermind.Network.Discovery.Discv4.Kademlia; +using Nethermind.Stats; +using Nethermind.Stats.Model; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv4.Kademlia +{ + [Parallelizable(ParallelScope.Self)] + [TestFixture] + public class NodeSourceTests + { + private TestKademlia _kademlia = null!; + private TestKademliaDiscovery _kademliaDiscovery = null!; + private IKademliaAdapter _discv4Adapter = null!; + private NodeSource _nodeSource = null!; + private NodeSession _nodeSession = null!; + private INodeStats _nodeStats = null!; + private ManualTimestamper _timestamper = null!; + private DiscoveryConfig _discoveryConfig = null!; + private KademliaConfig _kademliaConfig = null!; + + [SetUp] + public void Setup() + { + _kademlia = new(); + _kademliaDiscovery = new(); + _discv4Adapter = Substitute.For(); + + _discoveryConfig = new DiscoveryConfig + { + ConcurrentDiscoveryJob = 2 + }; + _kademliaConfig = new() + { + CurrentNodeId = new Node(TestItem.PublicKeyD, "127.0.0.1", 30303), + KSize = 1 + }; + + _nodeStats = Substitute.For(); + _timestamper = new(); + _timestamper.Set(new DateTimeOffset(2025, 5, 13, 21, 0, 0, TimeSpan.Zero).UtcDateTime); + + _nodeSession = new(_nodeStats, _timestamper); + _discv4Adapter.GetSession(Arg.Any()).Returns(_nodeSession); + + _nodeSource = new NodeSource( + _kademlia, + _kademliaDiscovery, + _discv4Adapter, + _discoveryConfig, + _kademliaConfig, + LimboLogs.Instance); + } + + [TearDown] + public async Task TearDown() => await _discv4Adapter.DisposeAsync(); + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_use_kademlia_discovery_to_find_nodes(CancellationToken token) + { + Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); + _nodeSession.OnPongReceived(node1.Address); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node1, node2); + _discv4Adapter.Ping(node1, Arg.Any()) + .Returns(true); + _discv4Adapter.Ping(node2, Arg.Any()) + .Returns(true); + + await using IAsyncEnumerator enumerator = _nodeSource.DiscoverNodes(token).GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.EqualTo(node1)); + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.EqualTo(node2)); + + Assert.That(_kademliaDiscovery.DiscoverNodesCalls, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_ping_nodes_that_have_not_received_pong(CancellationToken token) + { + _discoveryConfig.ConcurrentDiscoveryJob = 1; + Node node = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + _discv4Adapter.Ping(node, Arg.Any()) + .Returns(true); + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node); + + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + + // Assert - Verify that ping was called + await _discv4Adapter.Received(1).Ping( + Arg.Is(n => n == node), + Arg.Any()); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_skip_nodes_that_have_tried_ping_recently_without_pong(CancellationToken token) + { + Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); + + NodeSession session1 = new(_nodeStats, _timestamper); + NodeSession session2 = new(_nodeStats, _timestamper); + _discv4Adapter.GetSession(node1).Returns(session1); + _discv4Adapter.GetSession(node2).Returns(session2); + + // Set up session1 to have tried ping recently without pong + session1.OnPingSent(); + + // Set up session2 to have received a pong + session2.OnPongReceived(node2.Address); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node1, node2); + + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); + + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.EqualTo(node2)); + + await _discv4Adapter.DidNotReceive().Ping( + Arg.Is(n => n == node1), + Arg.Any()); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_handle_ping_timeout(CancellationToken token) + { + _discoveryConfig.ConcurrentDiscoveryJob = 1; + Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); + + _discv4Adapter.Ping(node1, Arg.Any()) + .Returns(false); + _discv4Adapter.Ping(node2, Arg.Any()) + .Returns(true); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node1, node2); + + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); + + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + Assert.That(enumerator.Current, Is.EqualTo(node2)); + + await _discv4Adapter.Received(1).Ping( + Arg.Is(n => n == node1), + Arg.Any()); + await _discv4Adapter.Received(1).Ping( + Arg.Is(n => n == node2), + Arg.Any()); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_emit_nodes_from_kademlia_events(CancellationToken token) + { + Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); + + _nodeSession.OnPongReceived(node1.Address); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node1); + + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); + + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + + _kademlia.RaiseNodeAdded(node2); + + // Continue iterating + await enumerator.MoveNextAsync(); + + Assert.That(enumerator.Current, Is.EqualTo(node2)); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_not_emit_duplicate_nodes(CancellationToken token) + { + Node node = new(TestItem.PublicKeyC, "192.168.1.1", 30303); + + _nodeSession.OnPongReceived(node.Address); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node, node); + + using AutoCancelTokenSource shortTimeout = token.CreateChildTokenSource(TimeSpan.FromMilliseconds(100)); + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(shortTimeout.Token); + + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + + Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync().AsTask()); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_pass_concurrent_discovery_jobs_to_kademlia_discovery(CancellationToken token) + { + Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); + + _nodeSession.OnPongReceived(node1.Address); + + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node1, node2); + + IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); + + await using IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); + await enumerator.MoveNextAsync(); + await enumerator.MoveNextAsync(); + + Assert.That(_kademliaDiscovery.ConcurrentDiscoveryJobs, Is.EqualTo(_discoveryConfig.ConcurrentDiscoveryJob)); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_stop_background_jobs_when_enumeration_is_disposed(CancellationToken token) + { + _discoveryConfig.ConcurrentDiscoveryJob = 1; + Node node = new(TestItem.PublicKeyA, "192.168.1.1", 30303); + _nodeSession.OnPongReceived(node.Address); + _kademliaDiscovery.DiscoverNodesHandler = (_, _, _) => CreateAsyncEnumerable(node); + + List nodes = await _nodeSource.DiscoverNodes(CancellationToken.None).Take(1).ToListAsync(token); + + Assert.That(nodes, Is.EqualTo(new[] { node })); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_release_event_reservation_when_channel_is_full(CancellationToken token) + { + _discoveryConfig.ConcurrentDiscoveryJob = 0; + Node starterNode = CreateNode(1000); + Node[] queuedNodes = Enumerable.Range(0, 65).Select(CreateNode).ToArray(); + + await using IAsyncEnumerator enumerator = _nodeSource.DiscoverNodes(token).GetAsyncEnumerator(token); + ValueTask firstMove = enumerator.MoveNextAsync(); + await Task.Yield(); + _kademlia.RaiseNodeAdded(starterNode); + + Assert.That(await firstMove.AsTask(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(starterNode)); + + foreach (Node node in queuedNodes) + { + _kademlia.RaiseNodeAdded(node); + } + + for (int i = 0; i < 64; i++) + { + Assert.That(await enumerator.MoveNextAsync(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(queuedNodes[i])); + } + + ValueTask retryMove = enumerator.MoveNextAsync(); + await Task.Yield(); + _kademlia.RaiseNodeAdded(queuedNodes[64]); + + Assert.That(await retryMove.AsTask(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(queuedNodes[64])); + } + + private static async IAsyncEnumerable CreateAsyncEnumerable(params IEnumerable items) + { + foreach (T item in items) + { + await Task.Yield(); // Add an await to make the method truly async + yield return item; + } + } + + private static Node CreateNode(int index) + { + byte[] publicKey = new byte[PublicKey.LengthInBytes]; + publicKey[60] = (byte)(index >> 24); + publicKey[61] = (byte)(index >> 16); + publicKey[62] = (byte)(index >> 8); + publicKey[63] = (byte)index; + return new Node(new PublicKey(publicKey), $"192.168.{index / 256}.{index % 256}", 30303); + } + + private sealed class TestKademlia : IKademlia + { + public event EventHandler? OnNodeAdded; + public event EventHandler? OnNodeRemoved { add { } remove { } } + + public void RaiseNodeAdded(Node node) => OnNodeAdded?.Invoke(this, node); + + public void AddOrRefresh(Node node) => throw new NotSupportedException(); + + public void Remove(Node node) => throw new NotSupportedException(); + + public Task Run(CancellationToken token) => Task.CompletedTask; + + public Task Bootstrap(CancellationToken token) => Task.CompletedTask; + + public Task LookupNodesClosest(PublicKey key, CancellationToken token, int? k = null) => + Task.FromResult(Array.Empty()); + + public IAsyncEnumerable LookupNodes(PublicKey key, CancellationToken token, int? maxResults = null) => + throw new NotSupportedException(); + + public Node[] GetKNeighbour(PublicKey target, Node? excluding = null, bool excludeSelf = false) => []; + + public Node[] GetAllAtDistance(int distance) => []; + + public IEnumerable IterateNodes() => []; + } + + private sealed class TestKademliaDiscovery : IKademliaDiscovery + { + public int DiscoverNodesCalls { get; private set; } + + public int ConcurrentDiscoveryJobs { get; private set; } + + public Func> DiscoverNodesHandler { private get; set; } = + (_, _, _) => CreateAsyncEnumerable(); + + public IAsyncEnumerable DiscoverNodes(int concurrentDiscoveryJobs, int lookupResultLimit, CancellationToken token) + { + DiscoverNodesCalls++; + ConcurrentDiscoveryJobs = concurrentDiscoveryJobs; + return DiscoverNodesHandler(concurrentDiscoveryJobs, lookupResultLimit, token); + } + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaNodeSourceTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaNodeSourceTests.cs deleted file mode 100644 index 4f37e0fdbe54..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/KademliaNodeSourceTests.cs +++ /dev/null @@ -1,254 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Nethermind.Core; -using Nethermind.Core.Crypto; -using Nethermind.Core.Extensions; -using Nethermind.Core.Test.Builders; -using Nethermind.Core.Utils; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats; -using Nethermind.Stats.Model; -using NSubstitute; -using NUnit.Framework; - -namespace Nethermind.Network.Discovery.Test.Discv4 -{ - [Parallelizable(ParallelScope.Self)] - [TestFixture] - public class KademliaNodeSourceTests - { - private IKademlia _kademlia = null!; - private IIteratorNodeLookup _lookup = null!; - private IKademliaDiscv4Adapter _discv4Adapter = null!; - private KademliaNodeSource _nodeSource = null!; - private NodeSession _nodeSession = null!; - private INodeStats _nodeStats = null!; - private ManualTimestamper _timestamper = null!; - private DiscoveryConfig _discoveryConfig = null!; - - [SetUp] - public void Setup() - { - _kademlia = Substitute.For>(); - _lookup = Substitute.For>(); - _discv4Adapter = Substitute.For(); - - _discoveryConfig = new DiscoveryConfig - { - ConcurrentDiscoveryJob = 2 - }; - - _nodeStats = Substitute.For(); - _timestamper = new(); - _timestamper.Set(new DateTimeOffset(2025, 5, 13, 21, 0, 0, TimeSpan.Zero).UtcDateTime); - - _nodeSession = new(_nodeStats, _timestamper); - _discv4Adapter.GetSession(Arg.Any()).Returns(_nodeSession); - - _nodeSource = new KademliaNodeSource( - _kademlia, - _lookup, - _discv4Adapter, - _discoveryConfig, - LimboLogs.Instance); - } - - [TearDown] - public async Task TearDown() => await _discv4Adapter.DisposeAsync(); - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_use_lookup_to_find_nodes(CancellationToken token) - { - Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); - _nodeSession.OnPongReceived(); - - _lookup.Lookup(Arg.Any(), token) - .Returns(CreateAsyncEnumerable(node1, node2)); - _discv4Adapter.Ping(node1, token) - .Returns(true); - _discv4Adapter.Ping(node2, token) - .Returns(true); - - IAsyncEnumerator enumerator = _nodeSource.DiscoverNodes(token).GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - Assert.That(enumerator.Current, Is.EqualTo(node1)); - await enumerator.MoveNextAsync(); - Assert.That(enumerator.Current, Is.EqualTo(node2)); - - _lookup.Received().Lookup(Arg.Any(), token); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_ping_nodes_that_have_not_received_pong(CancellationToken token) - { - Node node = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - int pingCount = 0; - _discv4Adapter.Ping(node, token) - .Returns(true) - .AndDoes(_ => Interlocked.Increment(ref pingCount)); - _lookup.Lookup(Arg.Any(), token) - .Returns(CreateAsyncEnumerable(node)); - - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - - Assert.That(() => Volatile.Read(ref pingCount), Is.GreaterThanOrEqualTo(2).After(5000, 50)); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_skip_nodes_that_have_tried_ping_recently_without_pong(CancellationToken token) - { - Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); - - NodeSession session1 = new(_nodeStats, _timestamper); - NodeSession session2 = new(_nodeStats, _timestamper); - _discv4Adapter.GetSession(node1).Returns(session1); - _discv4Adapter.GetSession(node2).Returns(session2); - - // Set up session1 to have tried ping recently without pong - session1.OnPingSent(); - - // Set up session2 to have received a pong - session2.OnPongReceived(); - - _lookup.Lookup(Arg.Any(), token) - .Returns(CreateAsyncEnumerable(node1, node2)); - - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); - - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - Assert.That(enumerator.Current, Is.EqualTo(node2)); - - await _discv4Adapter.DidNotReceive().Ping( - Arg.Is(n => n == node1), - token); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_handle_ping_timeout(CancellationToken token) - { - Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); - - int node1PingCount = 0; - _discv4Adapter.Ping(node1, token) - .Returns(false) - .AndDoes(_ => Interlocked.Increment(ref node1PingCount)); - _discv4Adapter.Ping(node2, token) - .Returns(true); - - _lookup.Lookup(Arg.Any(), token) - .Returns(CreateAsyncEnumerable(node1, node2)); - - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); - - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - Assert.That(enumerator.Current, Is.EqualTo(node2)); - - Assert.That(() => Volatile.Read(ref node1PingCount), Is.GreaterThanOrEqualTo(2).After(5000, 50)); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_emit_nodes_from_kademlia_events(CancellationToken token) - { - Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); - - _nodeSession.OnPongReceived(); - - _lookup.Lookup(Arg.Any(), token) - .Returns(CreateAsyncEnumerable(node1)); - - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); - - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - - // Simulate node added event - _kademlia.OnNodeAdded += Raise.Event>(null, node2); - - // Continue iterating - await enumerator.MoveNextAsync(); - - Assert.That(enumerator.Current, Is.EqualTo(node2)); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_not_emit_duplicate_nodes(CancellationToken token) - { - Node node = new(TestItem.PublicKeyC, "192.168.1.1", 30303); - - _nodeSession.OnPongReceived(); - - _lookup.Lookup(Arg.Any(), Arg.Any()) - .Returns(CreateAsyncEnumerable(node, node)); - - using AutoCancelTokenSource shortTimeout = token.CreateChildTokenSource(TimeSpan.FromMilliseconds(100)); - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(shortTimeout.Token); - - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(token); - await enumerator.MoveNextAsync(); - - Assert.ThrowsAsync(async () => await enumerator.MoveNextAsync().AsTask()); - } - - [Test] - [CancelAfter(10000)] - public async Task DiscoverNodes_should_use_multiple_concurrent_discovery_jobs(CancellationToken token) - { - Node node1 = new(TestItem.PublicKeyA, "192.168.1.1", 30303); - Node node2 = new(TestItem.PublicKeyB, "192.168.1.2", 30303); - - _nodeSession.OnPongReceived(); - - // Set up the lookup to return different nodes for different calls - int callCount = 0; - _lookup.Lookup(Arg.Any(), token) - .Returns(_ => - { - callCount++; - return callCount == 1 - ? CreateAsyncEnumerable(node1) - : CreateAsyncEnumerable(node2); - }); - - IAsyncEnumerable discoveryEnumerable = _nodeSource.DiscoverNodes(token); - - IAsyncEnumerator enumerator = discoveryEnumerable.GetAsyncEnumerator(); - await enumerator.MoveNextAsync(); - await enumerator.MoveNextAsync(); - - // Assert - Verify that lookup was called at least twice - _lookup.Received(2).Lookup( - Arg.Any(), - token); - } - - private static async IAsyncEnumerable CreateAsyncEnumerable(params IEnumerable items) - { - foreach (T item in items) - { - await Task.Yield(); // Add an await to make the method truly async - yield return item; - } - } - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryHandlerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NettyDiscoveryHandlerTests.cs similarity index 59% rename from src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryHandlerTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NettyDiscoveryHandlerTests.cs index 51fc3b2c890b..113ab4e52817 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryHandlerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NettyDiscoveryHandlerTests.cs @@ -20,14 +20,16 @@ using Nethermind.Logging; using Nethermind.Network.Config; using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Kademlia; +using Nethermind.Network.Discovery.Discv4.Messages; +using Nethermind.Network.Enr; using Nethermind.Network.Test.Builders; using Nethermind.Serialization.Rlp; using Nethermind.Stats.Model; using NSubstitute; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test +namespace Nethermind.Network.Discovery.Test.Discv4 { [Parallelizable(ParallelScope.None)] // Some test check for global metric [TestFixture] @@ -37,7 +39,7 @@ public class NettyDiscoveryHandlerTests private readonly PrivateKey _privateKey2 = new("3a1076bf45ab87712ad64ccb3b10217737f7faacbf2872e88fdd9a537d8fe266"); private List _channels = []; private List _discoveryHandlers = []; - private List _kademliaAdaptersMocks = []; + private List _kademliaAdaptersMocks = []; private readonly IPEndPoint _address = new(IPAddress.Loopback, 10001); private readonly IPEndPoint _address2 = new(IPAddress.Loopback, 10002); private int _channelActivatedCounter; @@ -50,11 +52,11 @@ public async Task Initialize() _discoveryHandlers = []; _kademliaAdaptersMocks = []; _channelActivatedCounter = 0; - IKademliaDiscv4Adapter? kademliaAdapterMock = Substitute.For(); + IKademliaAdapter? kademliaAdapterMock = Substitute.For(); kademliaAdapterMock.OnIncomingMsg(Arg.Any()).Returns(Task.CompletedTask); IMessageSerializationService? messageSerializationService = Build.A.SerializationService().WithDiscovery(_privateKey).TestObject; - IKademliaDiscv4Adapter? kademliaAdapterMock2 = Substitute.For(); + IKademliaAdapter? kademliaAdapterMock2 = Substitute.For(); kademliaAdapterMock2.OnIncomingMsg(Arg.Any()).Returns(Task.CompletedTask); IMessageSerializationService? messageSerializationService2 = Build.A.SerializationService().WithDiscovery(_privateKey).TestObject; @@ -99,7 +101,7 @@ public async Task PingSentReceivedTest() [Test] public async Task PongSentReceivedTest() { - PongMsg msg = new(_privateKey2.PublicKey, Timestamper.Default.UnixTime.SecondsLong + 1200, new byte[] { 1, 2, 3 }) + PongMsg msg = new(_privateKey2.PublicKey, Timestamper.Default.UnixTime.SecondsLong + 1200, TestItem.KeccakA.ValueHash256) { FarAddress = _address2 }; @@ -108,7 +110,7 @@ public async Task PongSentReceivedTest() await SleepWhileWaiting(); await _kademliaAdaptersMocks[1].Received(1).OnIncomingMsg(Arg.Is(static x => x.MsgType == MsgType.Pong)); - PongMsg msg2 = new(_privateKey.PublicKey, Timestamper.Default.UnixTime.SecondsLong + 1200, new byte[] { 1, 2, 3 }) + PongMsg msg2 = new(_privateKey.PublicKey, Timestamper.Default.UnixTime.SecondsLong + 1200, TestItem.KeccakA.ValueHash256) { FarAddress = _address }; @@ -161,15 +163,27 @@ public async Task NeighborsSentReceivedTest() await _kademliaAdaptersMocks[0].Received(1).OnIncomingMsg(Arg.Is(static x => x.MsgType == MsgType.Neighbors)); } - private (IKademliaDiscv4Adapter Adapter, NettyDiscoveryHandler Handler, IChannelHandlerContext Ctx, IMessageSerializationService Service) CreateHandler(NodeFilter? nodeFilter = null) + private (IKademliaAdapter Adapter, NettyDiscoveryHandler Handler, IChannelHandlerContext Ctx, IMessageSerializationService Service) CreateHandler( + NodeFilter? nodeFilter = null, + int? globalInboundMessageBurst = null, + int? inboundMessageQueueCapacity = null, + int? inboundMessageWorkerCount = null, + IMessageSerializationService? messageSerializationService = null) { - IKademliaDiscv4Adapter adapter = Substitute.For(); + IKademliaAdapter adapter = Substitute.For(); adapter.OnIncomingMsg(Arg.Any()).Returns(Task.CompletedTask); - IMessageSerializationService service = Build.A.SerializationService().WithDiscovery(_privateKey2).TestObject; + IMessageSerializationService service = messageSerializationService ?? Build.A.SerializationService().WithDiscovery(_privateKey2).TestObject; IChannel channel = Substitute.For(); - NettyDiscoveryHandler handler = nodeFilter is not null - ? new(adapter, channel, service, Timestamper.Default, LimboLogs.Instance, nodeFilter) - : new(adapter, channel, service, Timestamper.Default, LimboLogs.Instance); + NettyDiscoveryHandler handler = new( + adapter, + channel, + service, + Timestamper.Default, + LimboLogs.Instance, + nodeFilter, + globalInboundMessageBurst, + inboundMessageQueueCapacity, + inboundMessageWorkerCount); IChannelHandlerContext ctx = Substitute.For(); return (adapter, handler, ctx, service); } @@ -177,7 +191,7 @@ public async Task NeighborsSentReceivedTest() [Test] public void UndersizedPacketIsNotForwardedToDiscoveryManager() { - (IKademliaDiscv4Adapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService _) = CreateHandler(); + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService _) = CreateHandler(); byte[] data = new byte[50]; IPEndPoint from = IPEndPoint.Parse("127.0.0.1:10000"); @@ -217,10 +231,35 @@ public async Task FarFutureMessagesAreRejected() _ = _kademliaAdaptersMocks[1].DidNotReceive().OnIncomingMsg(Arg.Any()); } + [Test] + public async Task EnrResponseWithoutExpirationIsAccepted() + { + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(); + + EnrResponseMsg msg = BuildEnrResponse(_privateKey2); + IByteBuffer serialized = service.ZeroSerialize(msg); + byte[] data; + try + { + data = serialized.ReadAllBytesAsArray(); + } + finally + { + serialized.SafeRelease(); + } + + handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer(data), _address2, _address)); + + await SleepWhileWaiting(); + + await adapter.Received(1).OnIncomingMsg(Arg.Is(static m => m.MsgType == MsgType.EnrResponse)); + ctx.DidNotReceive().FireChannelRead(Arg.Any()); + } + [Test] public async Task RateLimitedMessagesAreIgnored() { - (IKademliaDiscv4Adapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(NodeFilter.CreateExact(16, TimeSpan.FromMinutes(1))); + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(NodeFilter.CreateExact(16, TimeSpan.FromMinutes(1))); using SemaphoreSlim called = new(0); adapter.When(x => x.OnIncomingMsg(Arg.Any())).Do(_ => called.Release()); @@ -233,12 +272,13 @@ public async Task RateLimitedMessagesAreIgnored() await Task.Delay(50); await adapter.Received(1).OnIncomingMsg(Arg.Any()); + ctx.DidNotReceive().FireChannelRead(Arg.Any()); } [Test] public async Task DefaultInboundRateLimiter_Allows_ShortBurstFromSameIp() { - (IKademliaDiscv4Adapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(); + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(); byte[] data = SerializePing(service); @@ -248,23 +288,81 @@ public async Task DefaultInboundRateLimiter_Allows_ShortBurstFromSameIp() await SleepWhileWaiting(); await adapter.Received(2).OnIncomingMsg(Arg.Any()); + ctx.DidNotReceive().FireChannelRead(Arg.Any()); } [Test] public async Task DefaultInboundRateLimiter_Drops_Message_AboveBurstLimit() { - (IKademliaDiscv4Adapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(); + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(); byte[] data = SerializePing(service); - for (int i = 0; i < 5; i++) + for (int i = 0; i < 9; i++) { handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer((byte[])data.Clone()), _address2, _address)); } await SleepWhileWaiting(); - await adapter.Received(4).OnIncomingMsg(Arg.Any()); + await adapter.Received(8).OnIncomingMsg(Arg.Any()); + } + + [Test] + public async Task GlobalInboundRateLimiter_Drops_Messages_AboveBurstLimit() + { + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler(globalInboundMessageBurst: 2); + using SemaphoreSlim called = new(0); + adapter.When(x => x.OnIncomingMsg(Arg.Any())).Do(_ => called.Release()); + + byte[] data = SerializePing(service); + + for (int i = 0; i < 3; i++) + { + IPEndPoint sender = new(IPAddress.Parse($"127.0.1.{i + 1}"), _address2.Port); + handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer((byte[])data.Clone()), sender, _address)); + } + + Assert.That(await called.WaitAsync(TimeSpan.FromSeconds(5)), Is.True); + Assert.That(await called.WaitAsync(TimeSpan.FromSeconds(5)), Is.True); + await Task.Delay(50); + + await adapter.Received(2).OnIncomingMsg(Arg.Any()); + } + + [Test] + public async Task InboundDispatchQueue_Drops_Messages_WhenFull() + { + IMessageSerializationService innerService = Build.A.SerializationService().WithDiscovery(_privateKey2).TestObject; + using ManualResetEventSlim deserializeEntered = new(); + using ManualResetEventSlim unblockDeserialize = new(); + BlockingSerializationService blockingService = new(innerService, deserializeEntered, unblockDeserialize); + (IKademliaAdapter adapter, NettyDiscoveryHandler handler, IChannelHandlerContext ctx, IMessageSerializationService service) = CreateHandler( + globalInboundMessageBurst: 64, + inboundMessageQueueCapacity: 1, + inboundMessageWorkerCount: 1, + messageSerializationService: blockingService); + int received = 0; + adapter.OnIncomingMsg(Arg.Any()).Returns(_ => + { + Interlocked.Increment(ref received); + return Task.CompletedTask; + }); + + byte[] data = SerializePing(service); + + handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer((byte[])data.Clone()), new IPEndPoint(IPAddress.Parse("127.0.2.1"), _address2.Port), _address)); + Assert.That(deserializeEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); + + for (int i = 1; i < 16; i++) + { + IPEndPoint sender = new(IPAddress.Parse($"127.0.2.{i + 1}"), _address2.Port); + handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer((byte[])data.Clone()), sender, _address)); + } + + unblockDeserialize.Set(); + + Assert.That(() => Interlocked.CompareExchange(ref received, 0, 0), Is.EqualTo(2).After(5000, 10)); } private byte[] SerializePing(IMessageSerializationService service) @@ -280,7 +378,17 @@ private byte[] SerializePing(IMessageSerializationService service) return data; } - private async Task StartUdpChannel(string address, int port, IKademliaDiscv4Adapter kademliaAdapter, IMessageSerializationService service) + private EnrResponseMsg BuildEnrResponse(PrivateKey signingKey) + { + NodeRecord nodeRecord = new(); + nodeRecord.SetEntry(new SecP256k1Entry(signingKey.CompressedPublicKey)); + nodeRecord.EnrSequence = 5; + NodeRecordSigner signer = new(new Ecdsa(), signingKey); + signer.Sign(nodeRecord); + return new EnrResponseMsg(_address, nodeRecord, TestItem.KeccakA); + } + + private async Task StartUdpChannel(string address, int port, IKademliaAdapter kademliaAdapter, IMessageSerializationService service) { MultithreadEventLoopGroup group = new(1); @@ -293,7 +401,7 @@ private async Task StartUdpChannel(string address, int port, IKademliaDiscv4Adap _channels.Add(await bootstrap.BindAsync(IPAddress.Parse(address), port)); } - private void InitializeChannel(IDatagramChannel channel, IKademliaDiscv4Adapter kademliaAdapter, IMessageSerializationService service) + private void InitializeChannel(IDatagramChannel channel, IKademliaAdapter kademliaAdapter, IMessageSerializationService service) { NettyDiscoveryHandler handler = new(kademliaAdapter, channel, service, new Timestamper(), LimboLogs.Instance); handler.OnChannelActivated += (_, _) => @@ -309,5 +417,30 @@ private void InitializeChannel(IDatagramChannel channel, IKademliaDiscv4Adapter private static async Task SleepWhileWaiting() => await Task.Delay((TestContext.CurrentContext.CurrentRepeatCount + 1) * 300); + + private sealed class BlockingSerializationService( + IMessageSerializationService innerService, + ManualResetEventSlim deserializeEntered, + ManualResetEventSlim unblockDeserialize) : IMessageSerializationService + { + private int _deserializeCalls; + + public IByteBuffer ZeroSerialize(T message, IByteBufferAllocator? allocator = null) where T : MessageBase + => innerService.ZeroSerialize(message, allocator); + + public T Deserialize(ArraySegment bytes) where T : MessageBase + { + if (typeof(T) == typeof(PingMsg) && Interlocked.Increment(ref _deserializeCalls) == 1) + { + deserializeEntered.Set(); + unblockDeserialize.Wait(TimeSpan.FromSeconds(10)); + } + + return innerService.Deserialize(bytes); + } + + public T Deserialize(IByteBuffer buffer) where T : MessageBase + => innerService.Deserialize(buffer); + } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSessionTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSessionTests.cs index 909ae06779fc..ee8046c16f90 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSessionTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSessionTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Net; using Nethermind.Core; using Nethermind.Network.Discovery.Discv4; using Nethermind.Stats; @@ -17,6 +18,7 @@ public class NodeSessionTests private INodeStats _nodeStats = null!; private ManualTimestamper _timestamper = null!; private NodeSession _nodeSession = null!; + private static readonly IPEndPoint TestEndpoint = new(IPAddress.Loopback, 30303); [SetUp] public void Setup() @@ -36,7 +38,7 @@ public void Setup() NodeSession.BondTimeout).SetName(nameof(NodeSession.HasReceivedPing)), new TestCaseData( (Func)(s => s.HasReceivedPong), - (Action)(s => s.OnPongReceived()), + (Action)(s => s.OnPongReceived(TestEndpoint)), NodeSession.BondTimeout).SetName(nameof(NodeSession.HasReceivedPong)), new TestCaseData( (Func)(s => s.HasTriedPingRecently), diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/NodeSourceToDiscV4FeederTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSourceToDiscV4FeederTests.cs similarity index 68% rename from src/Nethermind/Nethermind.Network.Discovery.Test/NodeSourceToDiscV4FeederTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSourceToDiscV4FeederTests.cs index 910b78bfaed7..6d6db6b3ab70 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/NodeSourceToDiscV4FeederTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv4/NodeSourceToDiscV4FeederTests.cs @@ -5,12 +5,13 @@ using System.Threading.Tasks; using Nethermind.Config; using Nethermind.Core.Test.Builders; +using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.Test; using Nethermind.Stats.Model; using NSubstitute; using NUnit.Framework; -namespace Nethermind.Network.Discovery.Test; +namespace Nethermind.Network.Discovery.Test.Discv4; public class NodeSourceToDiscV4FeederTests { @@ -23,10 +24,12 @@ public async Task Test_ShouldAddNodeToDiscover(CancellationToken token) IProcessExitSource processExitSource = Substitute.For(); processExitSource.Token.Returns(token); NodeSourceToDiscV4Feeder feeder = new(source, discoveryApp, processExitSource, 10); + TaskCompletionSource nodeAdded = new(TaskCreationOptions.RunContinuationsAsynchronously); + discoveryApp.When(x => x.AddNodeToDiscovery(Arg.Any())).Do(_ => nodeAdded.TrySetResult()); _ = feeder.Run(); source.AddNode(new Node(TestItem.PublicKeyA, TestItem.IPEndPointA)); - await Task.Delay(100); + await nodeAdded.Task.WaitAsync(token); discoveryApp.Received().AddNodeToDiscovery(Arg.Any()); } @@ -40,13 +43,22 @@ public async Task Test_ShouldLimitAddedNode(CancellationToken token) IProcessExitSource processExitSource = Substitute.For(); processExitSource.Token.Returns(token); NodeSourceToDiscV4Feeder feeder = new(source, discoveryApp, processExitSource, 10); + TaskCompletionSource expectedNodesAdded = new(TaskCreationOptions.RunContinuationsAsynchronously); + int addedNodes = 0; + discoveryApp.When(x => x.AddNodeToDiscovery(Arg.Any())).Do(_ => + { + if (Interlocked.Increment(ref addedNodes) == 10) + { + expectedNodesAdded.TrySetResult(); + } + }); _ = feeder.Run(); for (int i = 0; i < 20; i++) { source.AddNode(new Node(TestItem.PublicKeyA, TestItem.IPEndPointA)); } - await Task.Delay(100); + await expectedNodesAdded.Task.WaitAsync(token); discoveryApp.Received(10).AddNodeToDiscovery(Arg.Any()); } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/CodecTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/CodecTests.cs new file mode 100644 index 000000000000..6e8481d7d6f2 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/CodecTests.cs @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Core.Crypto; +using Nethermind.Core.Extensions; +using Nethermind.Core.Test.Modules; +using Nethermind.Crypto; +using Nethermind.Network.Discovery.Discv5; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Network.Enr; +using Nethermind.Serialization.Rlp; +using NUnit.Framework; +using System; +using System.Net; +using System.Threading.Tasks; + +namespace Nethermind.Network.Discovery.Test.Discv5; + +public class CodecTests +{ + private static readonly byte[] NodeAId = Bytes.FromHexString("0xaaaa8419e9f49d0083561b48287df592939a8d19947d8c0ef88f2a4856a69fbb"); + private static readonly byte[] NodeBId = Bytes.FromHexString("0xbbbb9d047f0488c0b5a93c1c3f2d8bafc7c8ff337024a55434a0d0555de64db9"); + private static readonly byte[] Devp2pPingRequestId = [0, 0, 0, 1]; + private const string GethNodeAPrivateKey = "0xeef77acb6c6a6eebc5b363a475ac583ec7eccdb42b6481424c60f59aa326547f"; + private const string GethNodeBPrivateKey = "0x66fb62bfbd66b9177a138c1e5cddbe4f7c30c343e94e68df8769459cb1cde628"; + + [Test] + public void CompressedAgreement_Matches_Devp2p_Vector() + { + CompressedPublicKey publicKey = new("0x039961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231"); + PrivateKey privateKey = new("0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + + byte[] sharedPoint = privateKey.GetCompressedSharedPoint(publicKey); + + Assert.That(sharedPoint.ToHexString(true), Is.EqualTo("0x033b11a2a1f214567e1537ce5e509ffd9b21373247f2a3ff6841f4976f53165e7e")); + } + + [Test] + public void KeyDerivation_Matches_Devp2p_Vector() + { + CompressedPublicKey destinationPublicKey = new("0x0317931e6e0840220642f230037d285d122bc59063221ef3226b1f403ddc69ca91"); + PrivateKey ephemeralPrivateKey = new("0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + byte[] secret = ephemeralPrivateKey.GetCompressedSharedPoint(destinationPublicKey); + byte[] challengeData = Bytes.FromHexString("0x000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000"); + + (byte[] initiatorKey, byte[] recipientKey) = PacketCodec.DeriveKeysForTest(secret, NodeAId, NodeBId, challengeData); + + Assert.That(initiatorKey.ToHexString(true), Is.EqualTo("0xdccc82d81bd610f4f76d3ebe97a40571")); + Assert.That(recipientKey.ToHexString(true), Is.EqualTo("0xac74bb8773749920b0d3a8881c173ec5")); + } + + [Test] + public void IdNonceSignature_Matches_Devp2p_Vector() + { + PrivateKey staticKey = new("0xfb757dc581730490a1d7a00deea65e9b1936924caaea8f44d476014856b68736"); + byte[] challengeData = Bytes.FromHexString("0x000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000"); + byte[] ephemeralPublicKey = Bytes.FromHexString("0x039961e4c2356d61bedb83052c115d311acb3a96f5777296dcf297351130266231"); + byte[] signingHash = PacketCodec.CalculateIdSignatureHashForTest(challengeData, ephemeralPublicKey, NodeBId); + + Signature signature = new Ecdsa().Sign(staticKey, new ValueHash256(signingHash)); + + Assert.That(signature.Bytes.ToArray().ToHexString(true), Is.EqualTo("0x94852a1e2318c4e5e9d422c98eaf19d1d90d876b29cd06ca7cb7546d0fff7b484fe86c09a064fe72bdbef73ba8e9c34df0cd2b53e9d65528c2c7f336d5dfc6e6")); + } + + [Test] + public void PacketCodec_Decodes_PingPacket_Devp2p_Vector() + { + byte[] packetBytes = CreateDevp2pPingPacketBytes(); + + bool decoded = PacketCodec.TryDecode(packetBytes, NodeBId, out Packet packet); + using (packet) + { + bool decrypted = PacketCodec.TryDecryptMessageForTest(in packet, new byte[16], out Discv5Message message); + + Assert.That(decoded, Is.True); + Assert.That(packet.Flag, Is.EqualTo(PacketFlag.Ordinary)); + Assert.That(packet.AuthData.ToArray(), Is.EqualTo(NodeAId)); + Assert.That(decrypted, Is.True); + Assert.That(message, Is.InstanceOf()); + PingMsg ping = (PingMsg)message; + AssertRequestId(ping.RequestId, Devp2pPingRequestId); + Assert.That(ping.EnrSequence, Is.EqualTo(2)); + message.Dispose(); + } + } + + [Test] + public void PacketCodec_Rejects_Packets_Larger_Than_Spec() + { + byte[] packetBytes = new byte[PacketCodec.MaxPacketSize + 1]; + + bool decoded = PacketCodec.TryDecode(packetBytes, NodeBId, out Packet packet); + using (packet) + { + Assert.That(decoded, Is.False); + } + } + + [Test] + public void PacketCodec_Decodes_Packets_Concurrently() + { + byte[] packetBytes = CreateDevp2pPingPacketBytes(); + using PacketCodec codec = CreateCodec(new PrivateKey(GethNodeBPrivateKey)); + + Parallel.For(0, 128, (int _) => + { + bool decoded = codec.TryDecode(packetBytes, out Packet packet); + using (packet) + { + Assert.That(decoded, Is.True); + Assert.That(packet.Flag, Is.EqualTo(PacketFlag.Ordinary)); + Assert.That(packet.AuthData.ToArray(), Is.EqualTo(NodeAId)); + } + }); + } + + [Test] + public void PacketCodec_Decodes_WhoAreYou_GoEthereum_Vector() + { + byte[] packetBytes = Bytes.FromHexString( + "0x00000000000000000000000000000000088b3d434277464933a1ccc59f5967ad" + + "1d6035f15e528627dde75cd68292f9e6c27d6b66c8100a873fcbaed4e16b8d"); + byte[] challengeData = Bytes.FromHexString("0x000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000"); + + bool decoded = PacketCodec.TryDecode(packetBytes, NodeBId, out Packet packet); + using (packet) + { + using PacketCodec codec = CreateCodec(new PrivateKey(GethNodeBPrivateKey)); + using Challenge challenge = codec.DecodeWhoAreYou(in packet); + + Assert.That(decoded, Is.True); + Assert.That(packet.Flag, Is.EqualTo(PacketFlag.WhoAreYou)); + Assert.That(packet.Nonce.Span.SequenceEqual(Bytes.FromHexString("0x0102030405060708090a0b0c")), Is.True); + Assert.That(packet.AuthData.Span[..16].SequenceEqual(Bytes.FromHexString("0x0102030405060708090a0b0c0d0e0f10")), Is.True); + Assert.That(challenge.EnrSequence, Is.Zero); + Assert.That(challenge.ChallengeData.SequenceEqual(challengeData), Is.True); + } + } + + [TestCase( + "0x00000000000000000000000000000000088b3d4342774649305f313964a39e55" + + "ea96c005ad521d8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d3" + + "4c4f53245d08da4bb252012b2cba3f4f374a90a75cff91f142fa9be3e0a5f3ef" + + "268ccb9065aeecfd67a999e7fdc137e062b2ec4a0eb92947f0d9a74bfbf44dfb" + + "a776b21301f8b65efd5796706adff216ab862a9186875f9494150c4ae06fa4d1" + + "f0396c93f215fa4ef524f1eadf5f0f4126b79336671cbcf7a885b1f8bd2a5d83" + + "9cf8", + 1UL, + "0x4f9fac6de7567d1e3b1241dffe90f662", + "0x000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000001", + false)] + [TestCase( + "0x00000000000000000000000000000000088b3d4342774649305f313964a39e55" + + "ea96c005ad539c8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d3" + + "4c4f53245d08da4bb23698868350aaad22e3ab8dd034f548a1c43cd246be9856" + + "2fafa0a1fa86d8e7a3b95ae78cc2b988ded6a5b59eb83ad58097252188b902b2" + + "1481e30e5e285f19735796706adff216ab862a9186875f9494150c4ae06fa4d1" + + "f0396c93f215fa4ef524e0ed04c3c21e39b1868e1ca8105e585ec17315e755e6" + + "cfc4dd6cb7fd8e1a1f55e49b4b5eb024221482105346f3c82b15fdaae36a3bb1" + + "2a494683b4a3c7f2ae41306252fed84785e2bbff3b022812d0882f06978df84a" + + "80d443972213342d04b9048fc3b1d5fcb1df0f822152eced6da4d3f6df27e70e" + + "4539717307a0208cd208d65093ccab5aa596a34d7511401987662d8cf62b1394" + + "71", + 0UL, + "0x53b1c075f41876423154e157470c2f48", + "0x000000000000000000000000000000006469736376350001010102030405060708090a0b0c00180102030405060708090a0b0c0d0e0f100000000000000000", + true)] + public void PacketCodec_Decodes_PingHandshake_GoEthereum_Vectors( + string packetHex, + ulong challengeEnrSequence, + string expectedReadKeyHex, + string challengeDataHex, + bool includesRecord) + { + byte[] packetBytes = Bytes.FromHexString(packetHex); + using Challenge challenge = new(challengeEnrSequence, Bytes.FromHexString(challengeDataHex)); + using PacketCodec codec = CreateCodec(new PrivateKey(GethNodeBPrivateKey)); + NodeRecord? knownRecord = includesRecord ? null : CreateNodeRecord(new PrivateKey(GethNodeAPrivateKey)); + + bool decoded = PacketCodec.TryDecode(packetBytes, NodeBId, out Packet packet); + using (packet) + { + bool decrypted = codec.TryDecryptHandshake(in packet, challenge, knownRecord, out Session session, out Discv5Message message, out NodeRecord? nodeRecord); + + Assert.That(decoded, Is.True); + Assert.That(packet.Flag, Is.EqualTo(PacketFlag.Handshake)); + Assert.That(decrypted, Is.True); + Assert.That(session.ReadKey.ToHexString(true), Is.EqualTo(expectedReadKeyHex)); + Assert.That(message, Is.InstanceOf()); + PingMsg ping = (PingMsg)message; + AssertRequestId(ping.RequestId, Devp2pPingRequestId); + Assert.That(ping.EnrSequence, Is.EqualTo(1)); + Assert.That(nodeRecord is not null, Is.EqualTo(includesRecord)); + message.Dispose(); + } + } + + [Test] + public void MessageCodec_Roundtrips_FindNode() + { + using FindNodeMsg message = new([0, 0, 0, 1], [255, 254, 256]); + + using NettyRlpStream encoded = MessageCodec.Encode(message); + using Discv5Message decoded = MessageCodec.Decode(encoded.AsSpan()); + + Assert.That(decoded, Is.InstanceOf()); + FindNodeMsg decodedFindNode = (FindNodeMsg)decoded; + Assert.That(decodedFindNode.RequestId, Is.EqualTo(message.RequestId)); + Assert.That(decodedFindNode.Distances, Is.EqualTo(message.Distances)); + } + + [TestCase(new byte[] { }, 1)] + [TestCase(new byte[] { 0x7f }, 1)] + [TestCase(new byte[] { 0x80 }, 2)] + [TestCase(new byte[] { 0x00, 0x01 }, 3)] + [TestCase(new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 }, 9)] + public void RequestId_GetRlpLength_ShouldMatchByteStringRules(byte[] requestId, int expectedLength) + { + RequestId value = RequestId.From(requestId); + + Assert.That(value.GetRlpLength(), Is.EqualTo(expectedLength)); + } + + [Test] + public void MessageCodec_Roundtrips_Pong() + { + using PongMsg message = new([0, 0, 0, 2], 3, IPAddress.Parse("192.0.2.1"), 30303); + + using NettyRlpStream encoded = MessageCodec.Encode(message); + using Discv5Message decoded = MessageCodec.Decode(encoded.AsSpan()); + + Assert.That(decoded, Is.InstanceOf()); + PongMsg decodedPong = (PongMsg)decoded; + Assert.That(decodedPong.RequestId, Is.EqualTo(message.RequestId)); + Assert.That(decodedPong.EnrSequence, Is.EqualTo(message.EnrSequence)); + Assert.That(decodedPong.RecipientIp, Is.EqualTo(message.RecipientIp)); + Assert.That(decodedPong.RecipientPort, Is.EqualTo(message.RecipientPort)); + } + + [Test] + public void MessageCodec_Rejects_Oversized_Pong_Ip() + { + byte[] message = CreateMessage(MessageType.Pong, Rlp.Encode( + Rlp.Encode(new byte[] { 1 }), + Rlp.Encode(1), + Rlp.Encode(new byte[17]), + Rlp.Encode(30303))); + + Assert.That(() => MessageCodec.Decode(message), Throws.InstanceOf()); + } + + [Test] + public void MessageCodec_Roundtrips_TalkReq() + { + using TalkReqMsg message = new([0, 0, 0, 3], "eth"u8.ToArray(), new byte[] { 1, 2, 3, 4 }); + + using NettyRlpStream encoded = MessageCodec.Encode(message); + ArrayPoolSpan owner = new(encoded.AsSpan().Length); + encoded.AsSpan().CopyTo(owner); + using Discv5Message decoded = MessageCodec.DecodeOwned(owner.AsReadOnlyMemory(), owner); + + Assert.That(decoded, Is.InstanceOf()); + TalkReqMsg decodedTalkReq = (TalkReqMsg)decoded; + Assert.That(decodedTalkReq.RequestId, Is.EqualTo(message.RequestId)); + Assert.That(decodedTalkReq.Protocol.ToArray(), Is.EqualTo(message.Protocol.ToArray())); + Assert.That(decodedTalkReq.Request.ToArray(), Is.EqualTo(message.Request.ToArray())); + } + + [Test] + public void MessageCodec_Roundtrips_TalkResp() + { + using TalkRespMsg message = new([0, 0, 0, 4], new byte[] { 5, 6, 7, 8 }); + + using NettyRlpStream encoded = MessageCodec.Encode(message); + ArrayPoolSpan owner = new(encoded.AsSpan().Length); + encoded.AsSpan().CopyTo(owner); + using Discv5Message decoded = MessageCodec.DecodeOwned(owner.AsReadOnlyMemory(), owner); + + Assert.That(decoded, Is.InstanceOf()); + TalkRespMsg decodedTalkResp = (TalkRespMsg)decoded; + Assert.That(decodedTalkResp.RequestId, Is.EqualTo(message.RequestId)); + Assert.That(decodedTalkResp.Response.ToArray(), Is.EqualTo(message.Response.ToArray())); + } + + [Test] + public void MessageCodec_Requires_Owned_Memory_For_Talk_Messages() + { + using TalkRespMsg message = new([0, 0, 0, 4], new byte[] { 5, 6, 7, 8 }); + using NettyRlpStream encoded = MessageCodec.Encode(message); + + Assert.That(() => MessageCodec.Decode(encoded.AsSpan()), Throws.TypeOf()); + } + + [Test] + public void MessageCodec_Roundtrips_Nodes_From_NonZero_ArraySegment() + { + NodeRecord skippedRecord = CreateNodeRecord(new PrivateKey(GethNodeAPrivateKey)); + NodeRecord expectedRecord = CreateNodeRecord(new PrivateKey(GethNodeBPrivateKey)); + NodeRecord[] records = [skippedRecord, expectedRecord]; + using NodesMsg message = new([0, 0, 0, 5], 1, new ArraySegment(records, 1, 1)); + + using NettyRlpStream encoded = MessageCodec.Encode(message); + using Discv5Message decoded = MessageCodec.Decode(encoded.AsSpan()); + + Assert.That(decoded, Is.InstanceOf()); + NodesMsg decodedNodes = (NodesMsg)decoded; + Assert.That(decodedNodes.RequestId, Is.EqualTo(message.RequestId)); + Assert.That(decodedNodes.Total, Is.EqualTo(message.Total)); + Assert.That(decodedNodes.Records.Count, Is.EqualTo(1)); + Assert.That(decodedNodes.Records[0].EnrString, Is.EqualTo(expectedRecord.EnrString)); + } + + [Test] + public void MessageCodec_Skips_Invalid_Enrs_In_Nodes() + { + NodeRecord expectedRecord = CreateNodeRecord(new PrivateKey(GethNodeBPrivateKey)); + byte[] invalidRecord = new byte[304]; + invalidRecord[0] = 0xf9; + invalidRecord[1] = 0x01; + invalidRecord[2] = 0x2d; + + Rlp data = Rlp.Encode( + Rlp.Encode(new byte[] { 1 }), + Rlp.Encode(1), + Rlp.Encode(new Rlp(invalidRecord), new Rlp(expectedRecord.ToRlpBytes()), new Rlp(invalidRecord))); + byte[] message = new byte[data.Length + 1]; + message[0] = (byte)MessageType.Nodes; + data.Bytes.CopyTo(message.AsSpan(1)); + + using Discv5Message decoded = MessageCodec.Decode(message); + + Assert.That(decoded, Is.InstanceOf()); + NodesMsg nodes = (NodesMsg)decoded; + Assert.That(nodes.Records.Count, Is.EqualTo(1)); + Assert.That(nodes.Records[0].EnrString, Is.EqualTo(expectedRecord.EnrString)); + } + + [Test] + public void MessageCodec_Rejects_Too_Many_FindNode_Distances() + { + Rlp[] distances = new Rlp[Distances.MaxCount + 1]; + Array.Fill(distances, Rlp.Encode(1)); + byte[] message = CreateMessage(MessageType.FindNode, Rlp.Encode(Rlp.Encode(new byte[] { 1 }), Rlp.Encode(distances))); + + Assert.That(() => MessageCodec.Decode(message), Throws.TypeOf()); + } + + [Test] + public void MessageCodec_Rejects_Too_Many_Nodes_Records() + { + Rlp[] records = new Rlp[17]; + Array.Fill(records, Rlp.Encode(Array.Empty())); + byte[] message = CreateMessage(MessageType.Nodes, Rlp.Encode(Rlp.Encode(new byte[] { 1 }), Rlp.Encode(1), Rlp.Encode(records))); + + Assert.That(() => MessageCodec.Decode(message), Throws.TypeOf()); + } + + private static PacketCodec CreateCodec(PrivateKey privateKey) + => new( + new InsecureProtectedPrivateKey(privateKey), + new CryptoRandom(), + new EthereumEcdsa(0)); + + private static byte[] CreateDevp2pPingPacketBytes() + => Bytes.FromHexString( + "0x00000000000000000000000000000000088b3d4342774649325f313964a39e55" + + "ea96c005ad52be8c7560413a7008f16c9e6d2f43bbea8814a546b7409ce783d3" + + "4c4f53245d08dab84102ed931f66d1492acb308fa1c6715b9d139b81acbdcc"); + + private static NodeRecord CreateNodeRecord(PrivateKey privateKey) => + TestEnrBuilder.BuildSigned(privateKey, tcpPort: null, udpPort: null); + + private static byte[] CreateMessage(MessageType messageType, Rlp payload) + { + byte[] message = new byte[payload.Length + 1]; + message[0] = (byte)messageType; + payload.Bytes.CopyTo(message.AsSpan(1)); + return message; + } + + private static void AssertRequestId(RequestId requestId, ReadOnlySpan expected) + { + Assert.That(requestId.Length, Is.EqualTo(expected.Length)); + + Span actual = stackalloc byte[RequestId.MaxLength]; + requestId.CopyTo(actual); + Assert.That(actual[..requestId.Length].SequenceEqual(expected), Is.True); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/Handlers/NodesResponseHandlerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/Handlers/NodesResponseHandlerTests.cs new file mode 100644 index 000000000000..18ce998f5dcf --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/Handlers/NodesResponseHandlerTests.cs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Net; +using System.Threading.Tasks; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Network.Discovery.Discv5.Kademlia.Handlers; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Network.Discovery.Discv5.Kademlia; +using Nethermind.Network.Enr; +using Nethermind.Stats.Model; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv5.Handlers; + +public class NodesResponseHandlerTests +{ + [TestCase("8.8.8.8", "127.0.0.1", 0)] + [TestCase("127.0.0.1", "127.0.0.1", 1)] + [TestCase("127.0.0.1", "192.0.2.1", 0)] + public void ShouldFilterRecordByReceiverAndRecordAddress(string receiverIp, string recordIp, int expectedCount) + { + Node receiver = new(TestItem.PublicKeyA, receiverIp, 30303); + NodeRecord record = CreateEnr(TestItem.PrivateKeyB, IPAddress.Parse(recordIp)); + NodesResponseHandler handler = CreateNodesResponseHandler(receiver, record); + + using NodesMsg nodes = new([1], 1, [record]); + handler.Handle(nodes); + + Assert.That(handler.GetNodes(), Has.Length.EqualTo(expectedCount)); + } + + [Test] + public void ShouldRejectNodesReadBeforeCompletion() + { + Node receiver = new(TestItem.PublicKeyA, "127.0.0.1", 30303); + NodeRecord record = CreateEnr(TestItem.PrivateKeyB, IPAddress.Loopback); + NodesResponseHandler handler = CreateNodesResponseHandler(receiver, record); + + Assert.That(handler.GetNodes, Throws.TypeOf()); + } + + [Test] + public async Task ShouldCollectConcurrentBatchesOnce() + { + Node receiver = new(TestItem.PublicKeyA, "127.0.0.1", 30303); + NodeRecord first = CreateEnr(TestItem.PrivateKeyB, IPAddress.Loopback); + NodeRecord second = CreateEnr(TestItem.PrivateKeyC, IPAddress.Loopback); + NodeRecord third = CreateEnr(TestItem.PrivateKeyD, IPAddress.Loopback); + NodeRecord fourth = CreateEnr(TestItem.PrivateKeyE, IPAddress.Loopback); + using Distances distances = CreateDistances(receiver, first, second, third, fourth); + NodesResponseHandler handler = new(receiver, distances, Hash256KademliaDistance.Instance, ExecutionLayerDiscv5RecordFilter.Instance); + + using NodesMsg firstBatch = new([1], 2, [first, second, first]); + using NodesMsg secondBatch = new([2], 2, [third, fourth, second]); + + await Task.WhenAll( + Task.Run(() => handler.Handle(firstBatch)), + Task.Run(() => handler.Handle(secondBatch))); + await handler.Task.WaitAsync(TimeSpan.FromSeconds(1)); + + Node[] nodes = handler.GetNodes(); + Assert.That(nodes, Has.Length.EqualTo(4)); + AssertUniqueNodeIds(nodes); + } + + private static NodeRecord CreateEnr(PrivateKey privateKey, IPAddress ipAddress) => + TestEnrBuilder.BuildSigned(privateKey, ipAddress, tcpPort: null); + + private static NodesResponseHandler CreateNodesResponseHandler(Node receiver, NodeRecord record) => + new(receiver, CreateDistances(receiver, record), Hash256KademliaDistance.Instance, ExecutionLayerDiscv5RecordFilter.Instance); + + private static Distances CreateDistances(Node receiver, params NodeRecord[] records) + { + int[] distances = new int[records.Length]; + for (int i = 0; i < records.Length; i++) + { + distances[i] = GetDistance(receiver, records[i]); + } + + return new Distances(distances); + } + + private static int GetDistance(Node receiver, NodeRecord record) + { + PublicKey nodeId = record.GetObj(EnrContentKey.SecP256k1)!.Decompress(); + return Hash256KademliaDistance.Instance.CalculateLogDistance(receiver.Id.Hash, nodeId.Hash); + } + + private static void AssertUniqueNodeIds(Node[] nodes) + { + for (int i = 0; i < nodes.Length; i++) + { + for (int j = i + 1; j < nodes.Length; j++) + { + Assert.That(nodes[i].Id.Hash, Is.Not.EqualTo(nodes[j].Id.Hash)); + } + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/KademliaAdapterTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/KademliaAdapterTests.cs new file mode 100644 index 000000000000..5ee5306d48a2 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/KademliaAdapterTests.cs @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Core.Test.Modules; +using Nethermind.Crypto; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Discovery.Discv5; +using Nethermind.Network.Discovery.Discv5.Kademlia; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Network.Enr; +using Nethermind.Stats.Model; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv5; + +public class KademliaAdapterTests +{ + private IKademlia _kademlia = null!; + private PacketCodec? _packetCodec; + + [SetUp] + public void SetUp() => _kademlia = Substitute.For>(); + + [TearDown] + public void TearDown() + { + _packetCodec?.Dispose(); + _packetCodec = null; + } + + [Test] + public void GetNodesAtDistances_ShouldMapEachDistanceToKademliaTable() + { + Node nodeA = CreateNode(TestItem.PublicKeyA, 1); + Node nodeB = CreateNode(TestItem.PublicKeyB, 2); + Node nodeC = CreateNode(TestItem.PublicKeyC, 3); + + _kademlia.GetAllAtDistance(10).Returns([nodeA, nodeB]); + _kademlia.GetAllAtDistance(11).Returns([nodeB, nodeC]); + _kademlia.ClearReceivedCalls(); + + KademliaAdapter adapter = CreateAdapter(); + + Node[] result = adapter.GetNodesAtDistances([10, 11]); + + Assert.That(result, Is.EqualTo(new[] { nodeA, nodeB, nodeC })); + _kademlia.Received(1).GetAllAtDistance(10); + _kademlia.Received(1).GetAllAtDistance(11); + } + + [Test] + public void GetNodesAtDistances_ShouldExcludeRequester() + { + Node requester = CreateNode(TestItem.PublicKeyA, 1); + Node returned = CreateNode(TestItem.PublicKeyB, 2); + + _kademlia.GetAllAtDistance(10).Returns([requester, returned]); + + KademliaAdapter adapter = CreateAdapter(); + + Node[] result = adapter.GetNodesAtDistances([10], requester); + + Assert.That(result, Is.EqualTo(new[] { returned })); + } + + [TestCase(-1)] + [TestCase(257)] + public void GetNodesAtDistances_ShouldRejectInvalidDistance(int distance) + { + KademliaAdapter adapter = CreateAdapter(); + + Assert.Throws(() => adapter.GetNodesAtDistances([distance])); + } + + [Test] + public void TryAcceptChallenge_ShouldLimitBurstPerIp() + { + KademliaAdapter adapter = CreateAdapter(); + IPEndPoint endpoint = IPEndPoint.Parse("192.0.2.1:30303"); + + for (int i = 0; i < 16; i++) + { + Assert.That(adapter.TryAcceptChallenge(endpoint), Is.True); + } + + Assert.That(adapter.TryAcceptChallenge(endpoint), Is.False); + } + + [TestCaseSource(nameof(AcceptableNodeRecordCases))] + public void IsAcceptableNodeRecord_ShouldValidateRecord(AcceptableNodeRecordCase testCase) + { + NodeRecord record = CreateEnr(testCase.PrivateKey, testCase.IpAddress, includeEth2: testCase.IncludeEth2); + + Assert.That( + KademliaAdapter.IsAcceptableNodeRecord( + NodeRecord.FromEnrString(record.EnrString), + testCase.ExpectedNodeId, + testCase.AllowNonRoutable, + ExecutionLayerDiscv5RecordFilter.Instance), + Is.EqualTo(testCase.ExpectedResult)); + } + + private KademliaAdapter CreateAdapter() + { + INodeRecordProvider nodeRecordProvider = Substitute.For(); + nodeRecordProvider.GetCurrentAsync(Arg.Any()).Returns(new ValueTask(CreateEnr(TestItem.PrivateKeyB, IPAddress.Loopback))); + _packetCodec?.Dispose(); + _packetCodec = new PacketCodec( + new InsecureProtectedPrivateKey(TestItem.PrivateKeyA), + new CryptoRandom(), + new EthereumEcdsa(0)); + + return new( + new Lazy>(_kademlia), + new NettyDiscoveryV5Handler(LimboLogs.Instance), + _packetCodec, + nodeRecordProvider, + new DiscoveryConfig(), + new CryptoRandom(), + Hash256KademliaDistance.Instance, + ExecutionLayerDiscv5RecordFilter.Instance, + LimboLogs.Instance); + } + + private static Node CreateNode(PublicKey publicKey, int hostSuffix) => + new(publicKey, $"192.168.1.{hostSuffix}", 30303); + + [Test] + public void TrySetKnownRecord_ShouldNotDowngradeSequence() + { + KademliaAdapter adapter = CreateAdapter(); + NodeRecord newer = CreateEnr(TestItem.PrivateKeyB, IPAddress.Parse("8.8.8.8"), enrSequence: 2); + NodeRecord stale = CreateEnr(TestItem.PrivateKeyB, IPAddress.Parse("8.8.4.4"), enrSequence: 1); + + Assert.That(adapter.TrySetKnownRecord(TestItem.PrivateKeyB.PublicKey.Hash, newer, out NodeRecord current), Is.True); + Assert.That(current, Is.SameAs(newer)); + + Assert.That(adapter.TrySetKnownRecord(TestItem.PrivateKeyB.PublicKey.Hash, stale, out current), Is.False); + using (Assert.EnterMultipleScope()) + { + Assert.That(current, Is.SameAs(newer)); + Assert.That(current.EnrSequence, Is.EqualTo(2)); + } + } + + private static NodeRecord CreateEnr(PrivateKey privateKey, IPAddress ipAddress, ulong enrSequence = 1, bool includeEth2 = false) => + TestEnrBuilder.BuildSigned( + privateKey, + ipAddress, + tcpPort: null, + enrSequence: enrSequence, + configureExtras: includeEth2 ? static enr => enr.SetEntry(new TestEth2Entry()) : null); + + private static IEnumerable AcceptableNodeRecordCases() + { + yield return new TestCaseData(new AcceptableNodeRecordCase( + TestItem.PrivateKeyB, + IPAddress.Parse("192.0.2.1"), + TestItem.PrivateKeyB.PublicKey.Hash, + AllowNonRoutable: true, + IncludeEth2: false, + ExpectedResult: false)).SetName("Rejects special-use record"); + yield return new TestCaseData(new AcceptableNodeRecordCase( + TestItem.PrivateKeyB, + IPAddress.Parse("8.8.8.8"), + TestItem.PrivateKeyA.PublicKey.Hash, + AllowNonRoutable: false, + IncludeEth2: false, + ExpectedResult: false)).SetName("Rejects node-id mismatch"); + yield return new TestCaseData(new AcceptableNodeRecordCase( + TestItem.PrivateKeyB, + IPAddress.Loopback, + TestItem.PrivateKeyB.PublicKey.Hash, + AllowNonRoutable: true, + IncludeEth2: false, + ExpectedResult: true)).SetName("Allows non-routable when requested"); + yield return new TestCaseData(new AcceptableNodeRecordCase( + TestItem.PrivateKeyB, + IPAddress.Parse("8.8.8.8"), + TestItem.PrivateKeyB.PublicKey.Hash, + AllowNonRoutable: false, + IncludeEth2: true, + ExpectedResult: false)).SetName("Rejects consensus-only record"); + } + + public readonly record struct AcceptableNodeRecordCase( + PrivateKey PrivateKey, + IPAddress IpAddress, + Hash256 ExpectedNodeId, + bool AllowNonRoutable, + bool IncludeEth2, + bool ExpectedResult); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/NodeSourceTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/NodeSourceTests.cs new file mode 100644 index 000000000000..159ada58b0b3 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/NodeSourceTests.cs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Discovery.Discv5.Kademlia; +using Nethermind.Network.Enr; +using Nethermind.Stats.Model; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv5; + +public class NodeSourceTests +{ + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_ShouldNotRetainDroppedNodesInRecentDedupe(CancellationToken token) + { + IKademlia kademlia = Substitute.For>(); + kademlia.IterateNodes().Returns(Array.Empty()); + NodeSource source = CreateSource(kademlia); + + await using IAsyncEnumerator enumerator = source.DiscoverNodes(token).GetAsyncEnumerator(token); + ValueTask firstMove = enumerator.MoveNextAsync(); + await Task.Yield(); + Node firstNode = CreateNode(1); + RaiseNode(kademlia, firstNode); + + Assert.That(await firstMove.AsTask(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(firstNode)); + + for (int i = 2; i < 66; i++) + { + RaiseNode(kademlia, CreateNode(i)); + } + + Node droppedNode = CreateNode(100); + RaiseNode(kademlia, droppedNode); + + for (int i = 2; i < 66; i++) + { + Assert.That(await enumerator.MoveNextAsync(), Is.True); + } + + ValueTask droppedMove = enumerator.MoveNextAsync(); + await Task.Yield(); + RaiseNode(kademlia, droppedNode); + + Assert.That(await droppedMove.AsTask(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(droppedNode)); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_ShouldEmitPeerCandidateWithTcpEndpoint(CancellationToken token) + { + IKademlia kademlia = Substitute.For>(); + kademlia.IterateNodes().Returns(Array.Empty()); + NodeSource source = CreateSource(kademlia); + + await using IAsyncEnumerator enumerator = source.DiscoverNodes(token).GetAsyncEnumerator(token); + ValueTask firstMove = enumerator.MoveNextAsync(); + await Task.Yield(); + RaiseNode(kademlia, CreateNode(1, tcpPort: 30303, udpPort: 30304)); + + Assert.That(await firstMove.AsTask(), Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(enumerator.Current.Id, Is.EqualTo(TestItem.PrivateKeys[1].PublicKey)); + Assert.That(enumerator.Current.Port, Is.EqualTo(30303)); + } + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_ShouldSkipConsensusOnlyEnrs(CancellationToken token) + { + Node consensusOnlyNode = CreateNode(1, includeEth2: true); + Node executionNode = CreateNode(2); + IKademlia kademlia = Substitute.For>(); + kademlia.IterateNodes().Returns([consensusOnlyNode, executionNode]); + NodeSource source = CreateSource(kademlia); + + await using IAsyncEnumerator enumerator = source.DiscoverNodes(token).GetAsyncEnumerator(token); + + Assert.That(await enumerator.MoveNextAsync(), Is.True); + Assert.That(enumerator.Current.Id, Is.EqualTo(TestItem.PrivateKeys[2].PublicKey)); + } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_ShouldEmitPeerCandidateFromActiveKademliaDiscovery(CancellationToken token) + { + IKademlia kademlia = Substitute.For>(); + kademlia.IterateNodes().Returns(Array.Empty()); + IKademliaDiscovery discovery = Substitute.For>(); + discovery.DiscoverNodes(1, Arg.Any(), Arg.Any()) + .Returns(CreateAsyncEnumerable(CreateNode(1, tcpPort: 30303, udpPort: 30304))); + + NodeSource source = new( + kademlia, + discovery, + new DiscoveryConfig { ConcurrentDiscoveryJob = 1 }, + new KademliaConfig { CurrentNodeId = CreateNode(0) }, + ExecutionLayerDiscv5RecordFilter.Instance, + LimboLogs.Instance); + + await using IAsyncEnumerator enumerator = source.DiscoverNodes(token).GetAsyncEnumerator(token); + + Assert.That(await enumerator.MoveNextAsync(), Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(enumerator.Current.Id, Is.EqualTo(TestItem.PrivateKeys[1].PublicKey)); + Assert.That(enumerator.Current.Port, Is.EqualTo(30303)); + } + } + + private static NodeSource CreateSource(IKademlia kademlia) + { + IKademliaDiscovery discovery = Substitute.For>(); + discovery.DiscoverNodes(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(CreateAsyncEnumerable()); + + return new NodeSource( + kademlia, + discovery, + new DiscoveryConfig { ConcurrentDiscoveryJob = 0 }, + new KademliaConfig { CurrentNodeId = CreateNode(0) }, + ExecutionLayerDiscv5RecordFilter.Instance, + LimboLogs.Instance); + } + + private static Node CreateNode(int index, int tcpPort = 30303, int udpPort = 30304, bool includeEth2 = false) + { + PrivateKey privateKey = TestItem.PrivateKeys[index]; + string host = $"192.168.1.{index + 1}"; + NodeRecord enr = TestEnrBuilder.BuildSigned( + privateKey, + IPAddress.Parse(host), + tcpPort: tcpPort, + udpPort: udpPort, + configureExtras: includeEth2 ? static enr => enr.SetEntry(new TestEth2Entry()) : null); + return new Node(privateKey.PublicKey, host, udpPort) + { + Enr = enr.EnrString + }; + } + + private static void RaiseNode(IKademlia kademlia, Node node) => + kademlia.OnNodeAdded += Raise.Event>(null, node); + + private static async IAsyncEnumerable CreateAsyncEnumerable(params IEnumerable items) + { + foreach (T item in items) + { + await Task.Yield(); + yield return item; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/WireTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/WireTests.cs new file mode 100644 index 000000000000..bd6f680a51ce --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Discv5/WireTests.cs @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using DotNetty.Buffers; +using DotNetty.Common.Utilities; +using DotNetty.Transport.Channels; +using DotNetty.Transport.Channels.Embedded; +using DotNetty.Transport.Channels.Sockets; +using Nethermind.Core.Crypto; +using Nethermind.Core.Test; +using Nethermind.Core.Test.Builders; +using Nethermind.Core.Test.Modules; +using Nethermind.Crypto; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Enr; +using Nethermind.Network.Discovery.Discv5; +using Nethermind.Network.Discovery.Discv5.Kademlia; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Serialization.Rlp; +using Nethermind.Stats.Model; +using NSubstitute; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Discv5; + +public class WireTests +{ + [Test] + public async Task Ping_Completes_After_WhoAreYou_Handshake() + { + IPEndPoint endpointA = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint endpointB = IPEndPoint.Parse("127.0.0.1:10001"); + await using TestPeer peerA = CreatePeer(TestItem.PrivateKeyA, endpointA); + await using TestPeer peerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + Node nodeB = new(TestItem.PrivateKeyB.PublicKey, endpointB) + { + Enr = peerB.NodeRecordProvider.Current.EnrString + }; + + using CancellationTokenSource cancellationSource = new(10_000); + Task runA = peerA.Adapter.RunAsync(cancellationSource.Token); + Task runB = peerB.Adapter.RunAsync(cancellationSource.Token); + + Task pingTask = peerA.Adapter.Ping(nodeB, cancellationSource.Token); + await PumpUntilComplete(pingTask, peerA, peerB, cancellationSource.Token); + await pingTask; + + await cancellationSource.CancelAsync(); + await Task.WhenAll(runA, runB); + + peerA.Kademlia.Received().AddOrRefresh(Arg.Is(node => node.Id.Equals(TestItem.PrivateKeyB.PublicKey) && !string.IsNullOrEmpty(node.Enr))); + peerB.Kademlia.Received().AddOrRefresh(Arg.Is(node => node.Id.Equals(TestItem.PrivateKeyA.PublicKey) && !string.IsNullOrEmpty(node.Enr))); + } + + [Test] + public async Task Ping_Rehandshakes_After_RemoteSessionLost() + { + IPEndPoint endpointA = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint endpointB = IPEndPoint.Parse("127.0.0.1:10001"); + await using TestPeer peerA = CreatePeer(TestItem.PrivateKeyA, endpointA); + await using TestPeer peerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + Node nodeB = new(TestItem.PrivateKeyB.PublicKey, endpointB) + { + Enr = peerB.NodeRecordProvider.Current.EnrString + }; + + using CancellationTokenSource cancellationSourceA = new(10_000); + using CancellationTokenSource cancellationSourceB = new(10_000); + Task runA = peerA.Adapter.RunAsync(cancellationSourceA.Token); + Task runB = peerB.Adapter.RunAsync(cancellationSourceB.Token); + + Task firstPing = peerA.Adapter.Ping(nodeB, cancellationSourceA.Token); + await PumpUntilComplete(firstPing, peerA, peerB, cancellationSourceA.Token); + await firstPing; + + await cancellationSourceB.CancelAsync(); + await runB; + + await using TestPeer restartedPeerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + using CancellationTokenSource cancellationSourceRestartedB = new(10_000); + Task runRestartedB = restartedPeerB.Adapter.RunAsync(cancellationSourceRestartedB.Token); + + Task secondPing = peerA.Adapter.Ping(nodeB, cancellationSourceA.Token); + await PumpUntilComplete(secondPing, peerA, restartedPeerB, cancellationSourceA.Token); + await secondPing; + + await cancellationSourceA.CancelAsync(); + await cancellationSourceRestartedB.CancelAsync(); + await Task.WhenAll(runA, runRestartedB); + } + + [Test] + public async Task Ping_Completes_With_HandshakeRecord_WithoutEndpoint() + { + IPEndPoint endpointA = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint endpointB = IPEndPoint.Parse("127.0.0.1:10001"); + await using TestPeer peerA = CreatePeer(TestItem.PrivateKeyA, endpointA, includeEndpointInRecord: false); + await using TestPeer peerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + Node nodeB = new(TestItem.PrivateKeyB.PublicKey, endpointB) + { + Enr = peerB.NodeRecordProvider.Current.EnrString + }; + + using CancellationTokenSource cancellationSource = new(10_000); + Task runA = peerA.Adapter.RunAsync(cancellationSource.Token); + Task runB = peerB.Adapter.RunAsync(cancellationSource.Token); + + Task pingTask = peerA.Adapter.Ping(nodeB, cancellationSource.Token); + await PumpUntilComplete(pingTask, peerA, peerB, cancellationSource.Token); + await pingTask; + + await cancellationSource.CancelAsync(); + await Task.WhenAll(runA, runB); + + peerB.Kademlia.Received().AddOrRefresh(Arg.Is(node => node.Id.Equals(TestItem.PrivateKeyA.PublicKey) && string.IsNullOrEmpty(node.Enr))); + } + + [Test] + public async Task InboundPing_Starts_EndpointCheck_PingBack() + { + IPEndPoint endpointA = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint endpointB = IPEndPoint.Parse("127.0.0.1:10001"); + await using TestPeer peerA = CreatePeer(TestItem.PrivateKeyA, endpointA); + await using TestPeer peerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + Node nodeB = new(TestItem.PrivateKeyB.PublicKey, endpointB) + { + Enr = peerB.NodeRecordProvider.Current.EnrString + }; + + using CancellationTokenSource cancellationSource = new(10_000); + Task runA = peerA.Adapter.RunAsync(cancellationSource.Token); + Task runB = peerB.Adapter.RunAsync(cancellationSource.Token); + + Task pingTask = peerA.Adapter.Ping(nodeB, cancellationSource.Token); + await PumpUntilComplete(pingTask, peerA, peerB, cancellationSource.Token); + await pingTask; + + await PumpUntil( + () => peerB.Kademlia.ReceivedCallsMatching( + kademlia => kademlia.AddOrRefresh(Arg.Is(node => node.Id.Equals(TestItem.PrivateKeyA.PublicKey))), + requiredNumberOfCalls: 2, + maxNumberOfCalls: int.MaxValue), + peerA, + peerB, + cancellationSource.Token); + + await cancellationSource.CancelAsync(); + await Task.WhenAll(runA, runB); + } + + [Test] + public async Task FindNeighbours_Returns_Records_At_Requested_Distance() + { + IPEndPoint endpointA = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint endpointB = IPEndPoint.Parse("127.0.0.1:10001"); + IPEndPoint endpointC = IPEndPoint.Parse("127.0.0.1:10002"); + await using TestPeer peerA = CreatePeer(TestItem.PrivateKeyA, endpointA); + await using TestPeer peerB = CreatePeer(TestItem.PrivateKeyB, endpointB); + await using TestPeer peerC = CreatePeer(TestItem.PrivateKeyC, endpointC); + Node nodeB = new(TestItem.PrivateKeyB.PublicKey, endpointB) + { + Enr = peerB.NodeRecordProvider.Current.EnrString + }; + Node nodeC = new(TestItem.PrivateKeyC.PublicKey, endpointC) + { + Enr = peerC.NodeRecordProvider.Current.EnrString + }; + using Distances requestedDistances = peerA.Adapter.GetLookupDistances(nodeB, TestItem.PrivateKeyC.PublicKey); + for (int i = 0; i < requestedDistances.Count; i++) + { + peerB.Kademlia.GetAllAtDistance(requestedDistances[i]).Returns([]); + } + + peerB.Kademlia.GetAllAtDistance(requestedDistances[0]).Returns([nodeC]); + + using CancellationTokenSource cancellationSource = new(10_000); + Task runA = peerA.Adapter.RunAsync(cancellationSource.Token); + Task runB = peerB.Adapter.RunAsync(cancellationSource.Token); + + Task findTask = peerA.Adapter.FindNeighbours(nodeB, TestItem.PrivateKeyC.PublicKey, cancellationSource.Token); + await PumpUntilComplete(findTask, peerA, peerB, cancellationSource.Token); + Node[]? nodes = await findTask; + + await cancellationSource.CancelAsync(); + await Task.WhenAll(runA, runB); + + Assert.That(nodes, Is.Not.Null); + Assert.That(nodes, Has.Length.EqualTo(1)); + Assert.That(nodes![0].Id, Is.EqualTo(TestItem.PrivateKeyC.PublicKey)); + peerA.Kademlia.Received().AddOrRefresh(Arg.Is(node => node.Id.Equals(TestItem.PrivateKeyC.PublicKey))); + } + + private static TestPeer CreatePeer(PrivateKey privateKey, IPEndPoint endpoint, bool includeEndpointInRecord = true) + { + IKademlia kademlia = Substitute.For>(); + NettyDiscoveryV5Handler handler = new(new TestLogManager()); + EmbeddedChannel channel = new(); + handler.InitializeChannel(channel); + + TestNodeRecordProvider nodeRecordProvider = new(privateKey, endpoint, includeEndpointInRecord); + PacketCodec packetCodec = new( + new InsecureProtectedPrivateKey(privateKey), + new CryptoRandom(), + new EthereumEcdsa(0)); + KademliaAdapter adapter = new( + new Lazy>(kademlia), + handler, + packetCodec, + nodeRecordProvider, + new DiscoveryConfig(), + new CryptoRandom(), + Hash256KademliaDistance.Instance, + ExecutionLayerDiscv5RecordFilter.Instance, + LimboLogs.Instance); + + return new TestPeer(adapter, handler, channel, packetCodec, kademlia, nodeRecordProvider, endpoint); + } + + private static async Task PumpUntilComplete(Task task, TestPeer peerA, TestPeer peerB, CancellationToken token) + { + while (!task.IsCompleted) + { + Pump(peerA, peerB); + Pump(peerB, peerA); + await Task.Delay(10, token); + } + + Pump(peerA, peerB); + Pump(peerB, peerA); + } + + private static async Task PumpUntil(Func condition, TestPeer peerA, TestPeer peerB, CancellationToken token) + { + while (!condition()) + { + Pump(peerA, peerB); + Pump(peerB, peerA); + await Task.Delay(10, token); + } + + Pump(peerA, peerB); + Pump(peerB, peerA); + } + + private static void Pump(TestPeer from, TestPeer to) + { + while (from.Channel.ReadOutbound() is { } packet) + { + try + { + byte[] data = packet.Content.ReadAllBytesAsArray(); + IChannelHandlerContext context = Substitute.For(); + to.Handler.ChannelRead(context, new DatagramPacket(Unpooled.WrappedBuffer(data), from.Endpoint, to.Endpoint)); + } + finally + { + ReferenceCountUtil.Release(packet); + } + } + } + + private sealed record TestPeer( + KademliaAdapter Adapter, + NettyDiscoveryV5Handler Handler, + EmbeddedChannel Channel, + PacketCodec PacketCodec, + IKademlia Kademlia, + TestNodeRecordProvider NodeRecordProvider, + IPEndPoint Endpoint) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + await Adapter.DisposeAsync(); + await Channel.CloseAsync(); + Channel.FinishAndReleaseAll(); + PacketCodec.Dispose(); + } + } + + private sealed class TestNodeRecordProvider(PrivateKey privateKey, IPEndPoint endpoint, bool includeEndpoint) : INodeRecordProvider + { + public NodeRecord Current { get; } = includeEndpoint + ? TestEnrBuilder.BuildSigned(privateKey, endpoint.Address, tcpPort: endpoint.Port, udpPort: endpoint.Port) + : TestEnrBuilder.BuildSignedWithoutEndpoint(privateKey); + + public ValueTask GetCurrentAsync(CancellationToken cancellationToken = default) => new(Current); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/E2EDiscoveryTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/E2EDiscoveryTests.cs index f692caf396a9..d5448ea02af8 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/E2EDiscoveryTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/E2EDiscoveryTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Autofac; @@ -26,11 +25,10 @@ namespace Nethermind.Network.Discovery.Test; [TestFixture(DiscoveryVersion.V5)] public class E2EDiscoveryTests(DiscoveryVersion discoveryVersion) { - private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan PeerDiscoveryTimeout = TimeSpan.FromSeconds(45); + private static readonly TimeSpan PeerDiscoveryPollInterval = TimeSpan.FromMilliseconds(100); - /// - /// Common code for all node - /// private IContainer CreateNode(PrivateKey nodeKey, IEnode? bootEnode = null) { ConfigProvider configProvider = new(); @@ -50,6 +48,7 @@ private IContainer CreateNode(PrivateKey nodeKey, IEnode? bootEnode = null) networkConfig.P2PPort = port; IDiscoveryConfig discoveryConfig = configProvider.GetConfig(); discoveryConfig.DiscoveryVersion = discoveryVersion; + discoveryConfig.UseDefaultDiscv5Bootnodes = false; return new ContainerBuilder() .AddModule(new PseudoNethermindModule(spec, configProvider, new TestLogManager())) @@ -67,8 +66,7 @@ private IContainer CreateNode(PrivateKey nodeKey, IEnode? bootEnode = null) [Parallelizable(ParallelScope.None)] public async Task TestDiscovery() { - if (discoveryVersion == DiscoveryVersion.V5) Assert.Ignore("DiscV5 does not seems to work."); - CancellationTokenSource cancellationTokenSource = new CancellationTokenSource().ThatCancelAfter(TestTimeout); + using CancellationTokenSource cancellationTokenSource = new CancellationTokenSource().ThatCancelAfter(TestTimeout); await using IContainer boot = CreateNode(TestItem.PrivateKeys[0]); IEnode bootEnode = boot.Resolve(); @@ -79,20 +77,73 @@ public async Task TestDiscovery() IContainer[] nodes = [boot, node1, node2, node3, node4]; - HashSet nodeKeys = nodes.Select(ctx => ctx.Resolve().PublicKey).ToHashSet(); + HashSet nodeKeys = GetNodeKeys(nodes); foreach (IContainer node in nodes) { await node.Resolve().StartDiscovery(cancellationTokenSource.Token); } - foreach (IContainer node in nodes) + Task[] waitTasks = new Task[nodes.Length]; + for (int i = 0; i < nodes.Length; i++) + { + waitTasks[i] = AssertPeerPoolContainsExpectedNodes(nodes[i], nodeKeys, cancellationTokenSource.Token); + } + + await Task.WhenAll(waitTasks); + } + + private static HashSet GetNodeKeys(IContainer[] nodes) + { + HashSet nodeKeys = []; + for (int i = 0; i < nodes.Length; i++) + { + nodeKeys.Add(nodes[i].Resolve().PublicKey); + } + + return nodeKeys; + } + + private static async Task AssertPeerPoolContainsExpectedNodes(IContainer node, HashSet nodeKeys, CancellationToken cancellationToken) + { + IPeerPool pool = node.Resolve(); + PublicKey localKey = node.Resolve().PublicKey; + HashSet expectedKeys = [.. nodeKeys]; + expectedKeys.Remove(localKey); + + using CancellationTokenSource timeoutCts = new(PeerDiscoveryTimeout); + using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + CancellationToken linkedToken = linkedCts.Token; + + while (!linkedToken.IsCancellationRequested) { - IPeerPool pool = node.Resolve(); - HashSet expectedKeys = [.. nodeKeys]; + HashSet actualKeys = GetPeerKeys(pool); + if (actualKeys.SetEquals(expectedKeys)) + { + return; + } - Assert.That(() => pool.Peers.Select(static kvp => kvp.Value.Node.Id).ToHashSet(), - Is.EquivalentTo(expectedKeys).After(15000, 100)); + try + { + await Task.Delay(PeerDiscoveryPollInterval, linkedToken); + } + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) + { + break; + } } + + Assert.That(GetPeerKeys(pool), Is.EquivalentTo(expectedKeys), $"Node {localKey} did not discover all peers before {PeerDiscoveryTimeout}."); + } + + private static HashSet GetPeerKeys(IPeerPool pool) + { + HashSet peerKeys = []; + foreach (KeyValuePair peer in pool.Peers) + { + peerKeys.Add(peer.Value.Node.Id); + } + + return peerKeys; } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DiscoveryKademliaConfigFactoryTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DiscoveryKademliaConfigFactoryTests.cs new file mode 100644 index 000000000000..be75fe51e37d --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DiscoveryKademliaConfigFactoryTests.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Test.Builders; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +public class DiscoveryKademliaConfigFactoryTests +{ + [Test] + public void Create_ShouldUseProvidedCurrentNode() + { + Node currentNode = new(TestItem.PublicKeyA, "192.0.2.10", 30304, true); + + KademliaConfig config = DiscoveryKademliaConfigFactory.Create( + currentNode, + [], + new DiscoveryConfig()); + + Assert.That(config.CurrentNodeId, Is.SameAs(currentNode)); + Assert.That(config.CurrentNodeId.Address, Is.EqualTo(currentNode.Address)); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DoubleEndedLruTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DoubleEndedLruTests.cs new file mode 100644 index 000000000000..a73eb997863b --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/DoubleEndedLruTests.cs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Kademlia; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +public class DoubleEndedLruTests +{ + [Test] + public void AddOrRefresh_ReturnsAddedUntilCapacity_ThenFull() + { + DoubleEndedLru lru = new(2); + + Assert.That(lru.AddOrRefresh("a", "1"), Is.EqualTo(BucketAddResult.Added)); + Assert.That(lru.AddOrRefresh("b", "2"), Is.EqualTo(BucketAddResult.Added)); + Assert.That(lru.AddOrRefresh("c", "3"), Is.EqualTo(BucketAddResult.Full)); + Assert.That(lru.Count, Is.EqualTo(2)); + Assert.That(lru.Contains("c"), Is.False); + } + + [Test] + public void AddOrRefresh_RefreshMovesEntryToHeadAndReturnsPreviousValue() + { + DoubleEndedLru lru = new(3); + lru.AddOrRefresh("a", "1"); + lru.AddOrRefresh("b", "2"); + lru.AddOrRefresh("c", "3"); + + Assert.That(lru.AddOrRefresh("a", "1b", out string? previous), Is.EqualTo(BucketAddResult.Refreshed)); + + Assert.That(previous, Is.EqualTo("1")); + Assert.That(lru.GetByKey("a"), Is.EqualTo("1b")); + Assert.That(lru.GetAll(), Is.EqualTo(new[] { "1b", "3", "2" })); + Assert.That(lru.GetAllWithKey(), Is.EqualTo(new[] { ("a", "1b"), ("c", "3"), ("b", "2") })); + } + + [Test] + public void TryPopHead_RemovesMostRecentEntry() + { + DoubleEndedLru lru = new(3); + lru.AddOrRefresh("a", "1"); + lru.AddOrRefresh("b", "2"); + + Assert.That(lru.TryPopHead(out string key, out string? value), Is.True); + Assert.That((key, value), Is.EqualTo(("b", "2"))); + Assert.That(lru.GetAll(), Is.EqualTo(new[] { "1" })); + + Assert.That(lru.TryPopHead(out _, out _), Is.True); + Assert.That(lru.TryPopHead(out _, out _), Is.False); + } + + [Test] + public void TryGetLast_ReturnsLeastRecentEntry() + { + DoubleEndedLru lru = new(3); + Assert.That(lru.TryGetLast(out _), Is.False); + + lru.AddOrRefresh("a", "1"); + lru.AddOrRefresh("b", "2"); + lru.AddOrRefresh("a", "1"); + + Assert.That(lru.TryGetLast(out string? last), Is.True); + Assert.That(last, Is.EqualTo("2")); + } + + [Test] + public void Remove_FreesCapacityForNewEntries() + { + DoubleEndedLru lru = new(2); + lru.AddOrRefresh("a", "1"); + lru.AddOrRefresh("b", "2"); + + Assert.That(lru.Remove("a"), Is.True); + Assert.That(lru.Remove("a"), Is.False); + Assert.That(lru.AddOrRefresh("c", "3"), Is.EqualTo(BucketAddResult.Added)); + Assert.That(lru.GetAll(), Is.EqualTo(new[] { "3", "2" })); + } + + [Test] + public void Clear_EmptiesAndAllowsReuse() + { + DoubleEndedLru lru = new(2); + lru.AddOrRefresh("a", "1"); + lru.AddOrRefresh("b", "2"); + + lru.Clear(); + + Assert.That(lru.Count, Is.Zero); + Assert.That(lru.GetAll(), Is.Empty); + Assert.That(lru.AddOrRefresh("c", "3"), Is.EqualTo(BucketAddResult.Added)); + Assert.That(lru.GetAll(), Is.EqualTo(new[] { "3" })); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256XorUtilsTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256KademliaDistanceTests.cs similarity index 67% rename from src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256XorUtilsTests.cs rename to src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256KademliaDistanceTests.cs index e88cdf643c14..233ce116d68c 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256XorUtilsTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Hash256KademliaDistanceTests.cs @@ -8,8 +8,9 @@ namespace Nethermind.Network.Discovery.Test.Kademlia; -public class Hash256XorUtilsTests +public class Hash256KademliaDistanceTests { + private static readonly Hash256KademliaDistance Distance = Hash256KademliaDistance.Instance; [TestCase("0x0000000000000000000000000000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000000000000000000000000000", @@ -52,26 +53,27 @@ public class Hash256XorUtilsTests "0x000000000000000000000000000000000000000000000000000000000001000f", 17)] public void TestDistance(string hash1, string hash2, string xosString, int expectedDistance) { - ValueHash256 xor = Hash256XorUtils.XorDistance(new(hash1), new(hash2)); + Hash256 xor = XorDistance(new(hash1), new(hash2)); Assert.That(xor.ToString(), Is.EqualTo(xosString.ToLower())); - Assert.That(Hash256XorUtils.CalculateLogDistance(new(hash1), new(hash2)), Is.EqualTo(expectedDistance)); - Assert.That(Hash256XorUtils.CalculateLogDistance(new(hash2), new(hash1)), Is.EqualTo(expectedDistance)); + Assert.That(Distance.CalculateLogDistance(new Hash256(hash1), new Hash256(hash2)), Is.EqualTo(expectedDistance)); + Assert.That(Distance.CalculateLogDistance(new Hash256(hash2), new Hash256(hash1)), Is.EqualTo(expectedDistance)); } [Test] public void TestGetRandomHash() { Random rand = new(0); - ValueHash256 randomized = new(); - rand.NextBytes(randomized.BytesAsSpan); + Span randomizedBytes = stackalloc byte[Hash256.Size]; + rand.NextBytes(randomizedBytes); + Hash256 randomized = new(randomizedBytes); void TestForDistance(int distance) { - ValueHash256 randHash = Hash256XorUtils.GetRandomHashAtDistance(randomized, distance, rand); - Assert.That(Hash256XorUtils.CalculateLogDistance(randomized, randHash), Is.EqualTo(distance)); + Hash256 randHash = Distance.GetRandomHashAtDistance(randomized, distance, rand); + Assert.That(Distance.CalculateLogDistance(randomized, randHash), Is.EqualTo(distance)); } - for (int i = 1; i < 256; i++) + for (int i = 0; i <= 256; i++) { rand = new(0); for (int j = 0; j < 10; j++) @@ -82,13 +84,35 @@ void TestForDistance(int distance) } + [TestCase(-1)] + [TestCase(257)] + public void GetRandomHashAtDistance_ShouldRejectInvalidDistance(int distance) + { + Hash256 hash = new("0x0000000000000000000000000000000000000000000000000000000000000000"); + + Assert.That(() => Distance.GetRandomHashAtDistance(hash, distance, new Random(0)), Throws.InstanceOf()); + } + [TestCase] public void TestDistanceCompare() { - ValueHash256 h1 = new("0x0010000000000000000000000000000000000000000000000000000000000000"); - ValueHash256 h2 = new("0x0110000000000000000000000000000000000000000000000000000000000000"); - ValueHash256 h3 = new("0x0000000000000000000000000000000000000000000000000000000000000000"); + Hash256 h1 = new("0x0010000000000000000000000000000000000000000000000000000000000000"); + Hash256 h2 = new("0x0110000000000000000000000000000000000000000000000000000000000000"); + Hash256 h3 = new("0x0000000000000000000000000000000000000000000000000000000000000000"); + + Assert.That(Distance.Compare(h1, h2, h3), Is.LessThan(0)); + } + + private static Hash256 XorDistance(Hash256 left, Hash256 right) + { + Span result = stackalloc byte[Hash256.Size]; + ReadOnlySpan leftBytes = left.Bytes; + ReadOnlySpan rightBytes = right.Bytes; + for (int i = 0; i < result.Length; i++) + { + result[i] = (byte)(leftBytes[i] ^ rightBytes[i]); + } - Assert.That(Hash256XorUtils.Compare(h1, h2, h3), Is.LessThan(0)); + return new Hash256(result); } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IdentityNodeHashProvider.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IdentityNodeHashProvider.cs deleted file mode 100644 index df4b9fb6cedf..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IdentityNodeHashProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; -using Nethermind.Network.Discovery.Kademlia; - -namespace Nethermind.Network.Discovery.Test.Kademlia; - -internal sealed class IdentityNodeHashProvider : INodeHashProvider -{ - public static readonly IdentityNodeHashProvider Instance = new(); - - public ValueHash256 GetHash(ValueHash256 node) => node; -} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Int32KademliaDistance.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Int32KademliaDistance.cs new file mode 100644 index 000000000000..e6918b4926fc --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/Int32KademliaDistance.cs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Numerics; +using Nethermind.Kademlia; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +internal sealed class Int32KademliaDistance : IKademliaDistance +{ + public static Int32KademliaDistance Instance { get; } = new(); + + public int MaxDistance => 32; + + public int Zero => 0; + + public int CalculateLogDistance(int left, int right) + { + uint distance = (uint)(left ^ right); + return distance == 0 ? 0 : MaxDistance - BitOperations.LeadingZeroCount(distance); + } + + public int Compare(int left, int right, int target) + => ((uint)(left ^ target)).CompareTo((uint)(right ^ target)); + + public bool GetBit(int key, int index) + => ((uint)key & (1u << (31 - index))) != 0; + + public int SetBit(int key, int index) + => key | (int)(1u << (31 - index)); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IntNodeHashProvider.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IntNodeHashProvider.cs new file mode 100644 index 000000000000..6158c2a68c2f --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/IntNodeHashProvider.cs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Kademlia; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +internal sealed class IntNodeHashProvider : INodeHashProvider +{ + public static readonly IntNodeHashProvider Instance = new(); + + public int GetHash(int node) => node; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTests.cs index ed4ece474a3f..382717ecd044 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTests.cs @@ -2,8 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Linq; -using Nethermind.Core.Crypto; -using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Kademlia; using NUnit.Framework; namespace Nethermind.Network.Discovery.Test.Kademlia; @@ -13,69 +12,102 @@ public class KBucketTests [Test] public void TryAddOrRefresh_ShouldLimitToK() { - KBucket bucket = new(5); - - ValueHash256[] toAdd = Enumerable.Range(0, 10).Select((k) => ValueKeccak.Compute(k.ToString())).ToArray(); - - foreach (ValueHash256 valueHash256 in toAdd) - { - bucket.TryAddOrRefresh(valueHash256, valueHash256, out _); - } + (KBucket bucket, int[] toAdd) = BuildFullBucket(); // Again - foreach (ValueHash256 valueHash256 in toAdd) - { - bucket.TryAddOrRefresh(valueHash256, valueHash256, out _); - } + AddNodes(bucket, toAdd); Assert.That(bucket.GetAll().ToHashSet(), Is.EquivalentTo(toAdd[..5].ToHashSet())); Assert.That(bucket.GetAllWithHash().ToHashSet(), Is.EquivalentTo(toAdd[..5].Select(static it => (it, it)).ToHashSet())); - foreach (ValueHash256 valueHash256 in toAdd[..5]) + foreach (int node in toAdd[..5]) { - Assert.That(bucket.ContainsNode(valueHash256), Is.True); - Assert.That(bucket.GetByHash(valueHash256), Is.EqualTo(valueHash256)); + Assert.That(bucket.ContainsNode(node), Is.True); + Assert.That(bucket.GetByHash(node), Is.EqualTo(node)); } } [Test] - public void TryAddOrRefresh_ShouldKeepSameCachedArray_WhenAddingSameNode() + public void GetAll_should_return_snapshot_when_adding_same_node() { - KBucket bucket = new(5); + (KBucket bucket, int[] toAdd) = BuildFullBucket(); - ValueHash256[] toAdd = Enumerable.Range(0, 10).Select((k) => ValueKeccak.Compute(k.ToString())).ToArray(); + int[] nodes = bucket.GetAll(); - foreach (ValueHash256 valueHash256 in toAdd) - { - bucket.TryAddOrRefresh(valueHash256, valueHash256, out _); - } + AddNodes(bucket, toAdd); - ValueHash256[] nodes = bucket.GetAll(); + int[] refreshedNodes = bucket.GetAll(); + Assert.That(refreshedNodes, Is.Not.SameAs(nodes)); + Assert.That(refreshedNodes.ToHashSet(), Is.EquivalentTo(nodes.ToHashSet())); + } - foreach (ValueHash256 valueHash256 in toAdd) - { - bucket.TryAddOrRefresh(valueHash256, valueHash256, out _); - } + [Test] + public void GetAll_should_not_keep_cached_array_for_large_bucket() + { + KBucket bucket = new(KBucket.DefaultReplacementCacheSize + 1); + AddNodes(bucket, Enumerable.Range(0, KBucket.DefaultReplacementCacheSize + 1).ToArray()); - Assert.That(bucket.GetAll(), Is.SameAs(nodes)); + int[] nodes = bucket.GetAll(); + + Assert.That(bucket.GetAll(), Is.Not.SameAs(nodes)); } [Test] - public void RemoveAndReplace_ShouldReplaceNodeWithLatestInReplacementCache() + public void TryAddOrRefresh_ShouldReplaceCachedNode_WhenRefreshingSameHashWithNewInstance() { - KBucket bucket = new(5); + KBucket bucket = new(5); + const int hash = 1; - ValueHash256[] toAdd = Enumerable.Range(0, 10).Select((k) => ValueKeccak.Compute(k.ToString())).ToArray(); + bucket.TryAddOrRefresh(hash, 10, out _); + bucket.TryAddOrRefresh(hash, 11, out _); - foreach (ValueHash256 valueHash256 in toAdd) - { - bucket.TryAddOrRefresh(valueHash256, valueHash256, out _); - } + Assert.That(bucket.GetByHash(hash), Is.EqualTo(11)); + Assert.That(bucket.GetAll(), Is.EqualTo(new[] { 11 })); + Assert.That(bucket.GetAllWithHash(), Is.EqualTo(new[] { (hash, 11) })); + } + + [Test] + public void RemoveAndReplace_ShouldReplaceNodeWithLatestInReplacementCache() + { + (KBucket bucket, int[] toAdd) = BuildFullBucket(); bucket.RemoveAndReplace(toAdd[0]); - ValueHash256[] expected = [.. toAdd[1..5], toAdd[9]]; + int[] expected = [.. toAdd[1..5], toAdd[9]]; Assert.That(bucket.GetAll().ToHashSet(), Is.EquivalentTo(expected.ToHashSet())); Assert.That(bucket.GetAllWithHash().ToHashSet(), Is.EquivalentTo(expected.Select(static it => (it, it)).ToHashSet())); } + + [Test] + public void Replacement_cache_should_not_scale_with_large_bucket_size() + { + const int bucketSize = KBucket.DefaultReplacementCacheSize * 2; + + KBucket bucket = new(bucketSize); + int[] nodes = Enumerable.Range(0, bucketSize + KBucket.DefaultReplacementCacheSize + 1).ToArray(); + + AddNodes(bucket, nodes); + foreach (int node in nodes[..bucketSize]) + { + bucket.RemoveAndReplace(node); + } + + Assert.That(bucket.Count, Is.EqualTo(KBucket.DefaultReplacementCacheSize)); + } + + private static (KBucket Bucket, int[] Nodes) BuildFullBucket() + { + KBucket bucket = new(5); + int[] nodes = Enumerable.Range(0, 10).ToArray(); + AddNodes(bucket, nodes); + return (bucket, nodes); + } + + private static void AddNodes(KBucket bucket, int[] nodes) + { + foreach (int node in nodes) + { + bucket.TryAddOrRefresh(node, node, out _); + } + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTreeTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTreeTests.cs index 2280e2d5d179..7f63e6738779 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTreeTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KBucketTreeTests.cs @@ -3,63 +3,61 @@ using System; using System.Linq; -using Nethermind.Core.Crypto; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Kademlia; using NUnit.Framework; namespace Nethermind.Network.Discovery.Test.Kademlia; public class KBucketTreeTests { - private static readonly ValueHash256 SelfHash = new("0x0000000000000000000000000000000000000000000000000000000000000000"); + private const int SelfHash = 0; - private static KBucketTree CreateTree(int k = 4, int beta = 0) => new( - new KademliaConfig { CurrentNodeId = SelfHash, KSize = k, Beta = beta }, - IdentityNodeHashProvider.Instance, - LimboLogs.Instance); + private static KBucketTree CreateTree(int k = 4, int beta = 0) => new( + new KademliaConfig { CurrentNodeId = SelfHash, KSize = k, Beta = beta }, + IntNodeHashProvider.Instance, + Int32KademliaDistance.Instance); - private static void Add(KBucketTree tree, ValueHash256 hash) => + private static void Add(KBucketTree tree, int hash) => tree.TryAddOrRefresh(hash, hash, out _); - private static ValueHash256 HashAtDistance(int distance, byte tag) => - Hash256XorUtils.GetRandomHashAtDistance(SelfHash, distance, new Random(tag)); - [Test] public void Split_should_preserve_lru_order_in_child_buckets() { - KBucketTree tree = CreateTree(k: 2, beta: 0); + KBucketTree tree = CreateTree(k: 2, beta: 0); - ValueHash256 left0 = HashAtDistance(255, 0x10); - ValueHash256 left1 = HashAtDistance(255, 0x11); - ValueHash256 right0 = HashAtDistance(254, 0x20); - ValueHash256 right1 = HashAtDistance(254, 0x21); + int left0 = KeyAtDistance(31, 0x10); + int left1 = KeyAtDistance(31, 0x11); + int right0 = KeyAtDistance(30, 0x20); + int right1 = KeyAtDistance(30, 0x21); Add(tree, left0); Add(tree, right0); Add(tree, left1); Add(tree, right1); - Assert.That(tree.GetAllAtDistance(255), Is.EqualTo(new[] { left1, left0 })); - Assert.That(tree.GetAllAtDistance(254), Is.EqualTo(new[] { right1, right0 })); + Assert.That(tree.GetAllAtDistance(31), Is.EqualTo(new[] { left1, left0 })); + Assert.That(tree.GetAllAtDistance(30), Is.EqualTo(new[] { right1, right0 })); } [Test] public void GetAllAtDistance_should_include_nodes_in_deeper_split_buckets() { - KBucketTree tree = CreateTree(k: 2, beta: 4); + KBucketTree tree = CreateTree(k: 2, beta: 4); - ValueHash256 deep1 = HashAtDistance(252, 0x40); - ValueHash256 deep2 = HashAtDistance(252, 0x41); - ValueHash256 deep3 = HashAtDistance(252, 0x42); + int deep1 = KeyAtDistance(28, 0x40); + int deep2 = KeyAtDistance(28, 0x41); + int deep3 = KeyAtDistance(28, 0x42); Add(tree, deep1); Add(tree, deep2); Add(tree, deep3); - ValueHash256[] expectedCandidates = [deep1, deep2, deep3]; - ValueHash256[] result = tree.GetAllAtDistance(252); + int[] expectedCandidates = [deep1, deep2, deep3]; + int[] result = tree.GetAllAtDistance(28); Assert.That(result, Is.SupersetOf(new[] { deep1, deep2 })); Assert.That(result.All(expectedCandidates.Contains), Is.True); } + + private static int KeyAtDistance(int distance, int suffix) + => Int32KademliaDistance.Instance.SetBit(suffix, Int32KademliaDistance.Instance.MaxDistance - distance); } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaSimulation.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaSimulation.cs index 66fb3d0423cf..f675f121e9a8 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaSimulation.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaSimulation.cs @@ -11,9 +11,11 @@ using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Logging; +using Nethermind.Kademlia; using Nethermind.Network.Discovery.Kademlia; using NonBlocking; using NUnit.Framework; +using TestKademlia = Nethermind.Kademlia.Kademlia; namespace Nethermind.Network.Discovery.Test.Kademlia; @@ -48,9 +50,9 @@ public async Task TestBootstrap() ValueHash256 node2Hash = RandomKeccak(rand); ValueHash256 node3Hash = RandomKeccak(rand); - Kademlia node1 = fabric.CreateNode(node1Hash); - Kademlia node2 = fabric.CreateNode(node2Hash); - Kademlia node3 = fabric.CreateNode(node3Hash); + TestKademlia node1 = fabric.CreateNode(node1Hash); + TestKademlia node2 = fabric.CreateNode(node2Hash); + TestKademlia node3 = fabric.CreateNode(node3Hash); Assert.That(node1.GetKNeighbour(Keccak.Zero, null).Select(n => n.Hash).ToArray(), Is.EquivalentTo(new[] { node1Hash })); @@ -84,7 +86,7 @@ public async Task TestKNearestNeighbour() ValueHash256 node2Hash = RandomKeccak(rand); ValueHash256 node3Hash = RandomKeccak(rand); - Kademlia node1 = fabric.CreateNode(node1Hash); + TestKademlia node1 = fabric.CreateNode(node1Hash); Assert.That( (await node1.LookupNodesClosest(node1Hash, cts.Token)) @@ -92,7 +94,7 @@ public async Task TestKNearestNeighbour() .ToArray(), Is.EquivalentTo(new[] { node1Hash })); - Kademlia node2 = fabric.CreateNode(node2Hash); + TestKademlia node2 = fabric.CreateNode(node2Hash); fabric.CreateNode(node3Hash); node1.AddOrRefresh(new TestNode(node2Hash)); @@ -117,13 +119,13 @@ public async Task SimulateLargeKNearestNeighbour() TestFabric fabric = CreateFabric(); Random rand = new(0); ValueHash256 mainNodeHash = RandomKeccak(rand); - Kademlia mainNode = fabric.CreateNode(mainNodeHash); + TestKademlia mainNode = fabric.CreateNode(mainNodeHash); List nodeIds = []; for (int i = 0; i < nodeCount; i++) { ValueHash256 nodeHash = RandomKeccak(rand); - Kademlia kad = fabric.CreateNode(nodeHash); + TestKademlia kad = fabric.CreateNode(nodeHash); kad.AddOrRefresh(new TestNode(mainNodeHash)); nodeIds.Add(nodeHash); } @@ -146,7 +148,7 @@ public async Task SimulateLargeKNearestNeighbour() { TestNode[] nodesClosest = await mainNode.LookupNodesClosest(targetNode, cts.Token); HashSet expectedNodeClosestK = nodeIds - .Order(Comparer.Create((n1, n2) => Hash256XorUtils.Compare(n1, n2, targetNode))) + .Order(Comparer.Create((n1, n2) => Hash256KademliaDistance.Instance.Compare(ToHash(n1), ToHash(n2), ToHash(targetNode)))) .Take(_config.KSize) .ToHashSet(); @@ -184,17 +186,7 @@ private static ValueHash256 RandomKeccak(Random rand) return val; } - private class ValueHashNodeHashProvider : IKeyOperator - { - public ValueHash256 GetKey(TestNode node) => node.Hash; - - public ValueHash256 GetKeyHash(ValueHash256 key) => key; - - public ValueHash256 CreateRandomKeyAtDistance(ValueHash256 nodePrefix, int depth) => - Hash256XorUtils.GetRandomHashAtDistance(nodePrefix, depth); - - public ValueHash256 GetHash(ValueHash256 key) => key; - } + private static Hash256 ToHash(ValueHash256 hash) => ValueHashKeyOperator.ToHash(hash); private class TestFabric(KademliaConfig config) { @@ -206,7 +198,7 @@ private class TestFabric(KademliaConfig config) public bool SimulateLatency { get; set; } = false; internal ConcurrentDictionary _nodes = new(); - readonly ValueHashNodeHashProvider _nodeHashProvider = new(); + private readonly ValueHashKeyOperator _nodeHashProvider = new(static node => node.Hash); private readonly Random _random = new(0); private bool TryGetReceiver(TestNode receiverHash, out ReceiverForNode contentKademliaMessageReceiver) @@ -221,16 +213,17 @@ private bool TryGetReceiver(TestNode receiverHash, out ReceiverForNode contentKa return false; } - public Kademlia CreateNode(ValueHash256 nodeID) + public TestKademlia CreateNode(ValueHash256 nodeID) { TestNode nodeIDTestNode = new(nodeID); ContainerBuilder builder = new(); builder - .AddModule(new KademliaModule()) + .AddModule(new KademliaModule()) .AddSingleton(new TestLogManager(LogLevel.Error)) .AddSingleton(new ManualTimestamper(new DateTime(2025, 5, 13, 21, 0, 0, DateTimeKind.Utc))) - .AddSingleton>(_nodeHashProvider) + .AddSingleton>(Hash256KademliaDistance.Instance) + .AddSingleton>(_nodeHashProvider) .AddSingleton(new KademliaConfig { CurrentNodeId = nodeIDTestNode, @@ -241,13 +234,13 @@ public Kademlia CreateNode(ValueHash256 nodeID) }) .AddSingleton>(new SenderForNode(nodeIDTestNode, this)) .AddSingleton() - .AddSingleton>(); + .AddSingleton(); IContainer container = builder.Build(); _nodes[nodeID] = container; - return container.Resolve>(); + return container.Resolve(); } private class SenderForNode(TestNode sender, TestFabric fabric) : IKademliaMessageSender diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaTests.cs index 1e2d6b6544ff..f0f108b43540 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/KademliaTests.cs @@ -1,14 +1,17 @@ // SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Autofac; using Nethermind.Core; using Nethermind.Core.Crypto; using Nethermind.Logging; +using Nethermind.Kademlia; using Nethermind.Network.Discovery.Kademlia; using NSubstitute; using NUnit.Framework; @@ -19,26 +22,27 @@ public class KademliaTests { private readonly IKademliaMessageSender _kademliaMessageSender = Substitute.For>(); - private Kademlia CreateKad(KademliaConfig config) => + private IContainer CreateKadContainer(KademliaConfig config) => new ContainerBuilder() - .AddModule(new KademliaModule()) + .AddModule(new KademliaModule()) .AddSingleton(new TestLogManager(LogLevel.Trace)) .AddSingleton(new ManualTimestamper(new System.DateTime(2025, 5, 13, 21, 0, 0, System.DateTimeKind.Utc))) - .AddSingleton>(new ValueHashNodeHashProvider()) + .AddSingleton>(Hash256KademliaDistance.Instance) + .AddSingleton>(new ValueHashKeyOperator(static node => node)) .AddSingleton(config) .AddSingleton(_kademliaMessageSender) - .AddSingleton>() - .Build() - .Resolve>(); + .AddSingleton>() + .Build(); [Test] public void TestNewNodeAdded() { - Kademlia kad = CreateKad(new KademliaConfig + using IContainer container = CreateKadContainer(new KademliaConfig { KSize = 5, Beta = 0, }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); int nodeAddedTriggered = 0; kad.OnNodeAdded += (sender, hash256) => nodeAddedTriggered++; @@ -54,11 +58,12 @@ public void TestNewNodeAdded() [Test] public void TestNodeRemoved() { - Kademlia kad = CreateKad(new KademliaConfig + using IContainer container = CreateKadContainer(new KademliaConfig { KSize = 5, Beta = 0, }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); int nodeRemovedTriggered = 0; ValueHash256 testHash = new("0x1111111111111111111111111111111111111111111111111111111111111111"); @@ -75,20 +80,52 @@ public void TestNodeRemoved() } [Test] - public async Task TestTooManyNode() + public void ShouldSeedBootnodes() + { + ValueHash256 bootNode = ValueKeccak.Compute("bootnode"); + using IContainer container = CreateKadContainer(new KademliaConfig + { + KSize = 5, + Beta = 0, + BootNodes = [bootNode], + }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); + + Assert.That(kad.IterateNodes(), Does.Contain(bootNode)); + } + + [Test] + [CancelAfter(10000)] + public async Task TestTooManyNode(CancellationToken token) { TaskCompletionSource pingSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource pingStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource nodeRemoved = new(TaskCreationOptions.RunContinuationsAsynchronously); _kademliaMessageSender .Ping(Arg.Any(), Arg.Any()) - .Returns(pingSource.Task); + .Returns(async call => + { + pingStarted.SetResult(); + return await pingSource.Task.WaitAsync(call.Arg()); + }); - Kademlia kad = CreateKad(new KademliaConfig + using IContainer container = CreateKadContainer(new KademliaConfig { KSize = 5, Beta = 0, + RefreshPingDelay = TimeSpan.Zero, }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); + + ValueHash256[] testHashes = Enumerable.Range(0, 10).Select((k) => RandomValueHashAtDistance(ValueKeccak.Zero, 250)).ToArray(); + kad.OnNodeRemoved += (_, node) => + { + if (node.Equals(testHashes[0])) + { + nodeRemoved.TrySetResult(); + } + }; - ValueHash256[] testHashes = Enumerable.Range(0, 10).Select((k) => Hash256XorUtils.GetRandomHashAtDistance(ValueKeccak.Zero, 250)).ToArray(); foreach (ValueHash256 valueHash256 in testHashes[..10]) { kad.AddOrRefresh(valueHash256); @@ -96,12 +133,12 @@ public async Task TestTooManyNode() Assert.That(kad.GetAllAtDistance(250).ToHashSet(), Is.EquivalentTo(testHashes[..5].ToHashSet())); - pingSource.SetCanceled(); - - await Task.Delay(100); + await pingStarted.Task.WaitAsync(token); + pingSource.SetResult(false); + await nodeRemoved.Task.WaitAsync(token); HashSet afterCancelled = (testHashes[1..5].Concat([testHashes[9]])).ToHashSet(); - Assert.That(() => kad.GetAllAtDistance(250).ToHashSet(), Is.EquivalentTo(afterCancelled).After(100)); + Assert.That(kad.GetAllAtDistance(250).ToHashSet(), Is.EquivalentTo(afterCancelled)); } [Test] @@ -112,61 +149,72 @@ public void TestGetKNeighbours() .Ping(Arg.Any(), Arg.Any()) .Returns(pingSource.Task); - Kademlia kad = CreateKad(new KademliaConfig + using IContainer container = CreateKadContainer(new KademliaConfig { CurrentNodeId = ValueKeccak.Compute("something"), KSize = 5, Beta = 0, }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); - ValueHash256[] testHashes = Enumerable.Range(0, 7).Select((k) => ValueKeccak.Compute(k.ToString())).ToArray(); - foreach (ValueHash256 valueHash256 in testHashes) + try { - kad.AddOrRefresh(valueHash256); + ValueHash256[] testHashes = Enumerable.Range(0, 7).Select((k) => ValueKeccak.Compute(k.ToString())).ToArray(); + foreach (ValueHash256 valueHash256 in testHashes) + { + kad.AddOrRefresh(valueHash256); + } + + Assert.That(kad.GetKNeighbour(ValueKeccak.Zero), Has.Length.EqualTo(5)); + Assert.That(kad.GetKNeighbour(kad.CurrentNode), Does.Contain(kad.CurrentNode)); + foreach (ValueHash256 testHash in testHashes) + { + // It must return K items exactly, taking from other bucket if necessary. + Assert.That(kad.GetKNeighbour(testHash), Has.Length.EqualTo(5)); + + // It must find the closest one at least. + Assert.That(kad.GetKNeighbour(testHash), Does.Contain(testHash)); + + // It must exclude a node when hash is specified + Assert.That(kad.GetKNeighbour(testHash, testHash), Has.Length.EqualTo(5)); + Assert.That(kad.GetKNeighbour(testHash, excludeSelf: true), Does.Not.Contain(kad.CurrentNode)); + } } - - Assert.That(kad.GetKNeighbour(ValueKeccak.Zero), Has.Length.EqualTo(5)); - Assert.That(kad.GetKNeighbour(kad.CurrentNode), Does.Contain(kad.CurrentNode)); - foreach (ValueHash256 testHash in testHashes) + finally { - // It must return K items exactly, taking from other bucket if necessary. - Assert.That(kad.GetKNeighbour(testHash), Has.Length.EqualTo(5)); - - // It must find the closest one at least. - Assert.That(kad.GetKNeighbour(testHash), Does.Contain(testHash)); - - // It must exclude a node when hash is specified - Assert.That(kad.GetKNeighbour(testHash, testHash), Has.Length.EqualTo(5)); - Assert.That(kad.GetKNeighbour(testHash, excludeSelf: true), Does.Not.Contain(kad.CurrentNode)); + pingSource.TrySetCanceled(); } } [Test] - public async Task TestTooManyNodeWithAcceleratedLookup() + [CancelAfter(10000)] + public void TestTooManyNodeWithAcceleratedLookup() { _kademliaMessageSender .Ping(Arg.Any(), Arg.Any()) .Returns(true); - Kademlia kad = CreateKad(new KademliaConfig + using IContainer container = CreateKadContainer(new KademliaConfig { KSize = 5, Beta = 1, + RefreshPingDelay = TimeSpan.Zero, }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); ValueHash256[] testHashes = new IEnumerable[] { Enumerable.Range(0, 5).Select((k) => - Hash256XorUtils.GetRandomHashAtDistance(new("0x0000000000000000000000000000000000000000000000000000000000000000"), 248) + RandomValueHashAtDistance(new("0x0000000000000000000000000000000000000000000000000000000000000000"), 248) ), Enumerable.Range(0, 5).Select((k) => - Hash256XorUtils.GetRandomHashAtDistance(new("0x0100000000000000000000000000000000000000000000000000000000000000"), 248) + RandomValueHashAtDistance(new("0x0100000000000000000000000000000000000000000000000000000000000000"), 248) ), Enumerable.Range(0, 5).Select((k) => - Hash256XorUtils.GetRandomHashAtDistance(new("0x0200000000000000000000000000000000000000000000000000000000000000"), 248) + RandomValueHashAtDistance(new("0x0200000000000000000000000000000000000000000000000000000000000000"), 248) ), Enumerable.Range(0, 5).Select((k) => - Hash256XorUtils.GetRandomHashAtDistance(new("0x0300000000000000000000000000000000000000000000000000000000000000"), 248) + RandomValueHashAtDistance(new("0x0300000000000000000000000000000000000000000000000000000000000000"), 248) ), }.SelectMany(it => it).ToArray(); @@ -175,19 +223,46 @@ public async Task TestTooManyNodeWithAcceleratedLookup() kad.AddOrRefresh(valueHash256); } - await Task.Delay(100); - Assert.That(kad.GetAllAtDistance(248).ToHashSet(), Is.EquivalentTo(testHashes[..5].ToHashSet())); - Assert.That(kad.GetAllAtDistance(249).ToHashSet(), Is.EquivalentTo(testHashes[5..10].ToHashSet())); - Assert.That(kad.GetAllAtDistance(250).ToHashSet(), Is.EquivalentTo(testHashes[10..].ToHashSet())); + HashSet expected248 = testHashes[..5].ToHashSet(); + HashSet expected249 = testHashes[5..10].ToHashSet(); + HashSet expected250 = testHashes[10..].ToHashSet(); + Assert.That(kad.GetAllAtDistance(248).ToHashSet(), Is.EquivalentTo(expected248)); + Assert.That(kad.GetAllAtDistance(249).ToHashSet(), Is.EquivalentTo(expected249)); + Assert.That(kad.GetAllAtDistance(250).ToHashSet(), Is.EquivalentTo(expected250)); } - private class ValueHashNodeHashProvider : IKeyOperator + [Test] + public void PruneLastBucketRefreshTicks_removes_stale_prefixes_even_when_counts_match() { - public ValueHash256 GetKey(ValueHash256 node) => node; + using IContainer container = CreateKadContainer(new KademliaConfig + { + KSize = 5, + Beta = 0, + }); + Nethermind.Kademlia.Kademlia kad = container.Resolve>(); + + Hash256 activePrefix = new("0x1111111111111111111111111111111111111111111111111111111111111111"); + Hash256 stalePrefix = new("0x2222222222222222222222222222222222222222222222222222222222222222"); + Dictionary lastRefreshTicks = GetLastBucketRefreshTicks(kad); + lastRefreshTicks[activePrefix] = 1; + lastRefreshTicks[stalePrefix] = 2; - public ValueHash256 GetKeyHash(ValueHash256 key) => key; + HashSet activePrefixes = [activePrefix, new("0x3333333333333333333333333333333333333333333333333333333333333333")]; - public ValueHash256 CreateRandomKeyAtDistance(ValueHash256 nodePrefix, int depth) => - Hash256XorUtils.GetRandomHashAtDistance(nodePrefix, depth); + typeof(Nethermind.Kademlia.Kademlia) + .GetMethod("PruneLastBucketRefreshTicks", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(kad, [activePrefixes]); + + Assert.That(lastRefreshTicks.ContainsKey(activePrefix), Is.True); + Assert.That(lastRefreshTicks.ContainsKey(stalePrefix), Is.False); } + + private static ValueHash256 RandomValueHashAtDistance(ValueHash256 currentHash, int distance) => + ValueHashKeyOperator.ToValueHash( + Hash256KademliaDistance.Instance.GetRandomHashAtDistance(ValueHashKeyOperator.ToHash(currentHash), distance)); + + private static Dictionary GetLastBucketRefreshTicks(Nethermind.Kademlia.Kademlia kad) + => (Dictionary)typeof(Nethermind.Kademlia.Kademlia) + .GetField("_lastBucketRefreshTicks", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(kad)!; } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/LookupKNearestNeighbourTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/LookupKNearestNeighbourTests.cs index 12372ee6c18d..62cd25a0b70f 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/LookupKNearestNeighbourTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/LookupKNearestNeighbourTests.cs @@ -3,11 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Nethermind.Core.Crypto; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Kademlia; using NSubstitute; using NUnit.Framework; @@ -15,32 +14,32 @@ namespace Nethermind.Network.Discovery.Test.Kademlia; public class LookupKNearestNeighbourTests { - private static readonly ValueHash256 Self = new("0x0000000000000000000000000000000000000000000000000000000000000000"); - private static readonly ValueHash256 Seed1 = new("0x1100000000000000000000000000000000000000000000000000000000000000"); - private static readonly ValueHash256 Seed2 = new("0x2200000000000000000000000000000000000000000000000000000000000000"); - private static readonly ValueHash256 Seed3 = new("0x3300000000000000000000000000000000000000000000000000000000000000"); - private static readonly ValueHash256 N1 = new("0x4400000000000000000000000000000000000000000000000000000000000000"); - private static readonly ValueHash256 N2 = new("0x5500000000000000000000000000000000000000000000000000000000000000"); - - private static (LookupKNearestNeighbour Lookup, IRoutingTable Routing, INodeHealthTracker Health) CreateLookup(int alpha, TimeSpan hardTimeout, ValueHash256[] seeds) + private const int Self = 0; + private const int Seed1 = 1; + private const int Seed2 = 2; + private const int Seed3 = 3; + private const int N1 = 4; + private const int N2 = 5; + + private static (LookupKNearestNeighbour Lookup, IRoutingTable Routing, INodeHealthTracker Health) CreateLookup(int alpha, TimeSpan hardTimeout, int[] seeds) { - IRoutingTable routing = Substitute.For>(); - routing.GetKNearestNeighbour(Arg.Any(), Arg.Any()).Returns(seeds); + IRoutingTable routing = Substitute.For>(); + routing.GetKNearestNeighbour(Arg.Any(), Arg.Any()).Returns(seeds); - INodeHealthTracker health = Substitute.For>(); + INodeHealthTracker health = Substitute.For>(); - LookupKNearestNeighbour lookup = new( + LookupKNearestNeighbour lookup = new( routing, - IdentityNodeHashProvider.Instance, + IntNodeHashProvider.Instance, + Int32KademliaDistance.Instance, health, - new KademliaConfig + new KademliaConfig { CurrentNodeId = Self, Alpha = alpha, KSize = 8, LookupFindNeighbourHardTimeout = hardTimeout, - }, - LimboLogs.Instance); + }); return (lookup, routing, health); } @@ -50,16 +49,14 @@ private static (LookupKNearestNeighbour Lookup, IRou [CancelAfter(10000)] public async Task Lookup_should_unblock_on_mid_flight_cancellation(int alpha, CancellationToken token) { - (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = + (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = CreateLookup(alpha, TimeSpan.FromSeconds(30), [Seed1]); using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); - // Signalled once a findNeighbour request is actually dispatched, so cancellation deterministically - // interrupts an in-flight request (which records OnRequestFailed) instead of racing worker startup. TaskCompletionSource requestInFlight = new(TaskCreationOptions.RunContinuationsAsynchronously); - Task task = lookup.Lookup( + Task task = lookup.Lookup( Seed1, 8, async (_, t) => @@ -82,7 +79,7 @@ public async Task Lookup_should_unblock_on_mid_flight_cancellation(int alpha, Ca [CancelAfter(10000)] public async Task Lookup_should_record_request_failure_on_hard_timeout(int alpha, CancellationToken token) { - (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = + (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = CreateLookup(alpha, TimeSpan.FromMilliseconds(100), [Seed1]); _ = await lookup.Lookup( @@ -102,39 +99,140 @@ public async Task Lookup_should_record_request_failure_on_hard_timeout(int alpha [CancelAfter(10000)] public async Task Lookup_should_not_mark_node_healthy_when_find_neighbours_returns_null(CancellationToken token) { - (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = + (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = CreateLookup(1, TimeSpan.FromSeconds(10), [Seed1]); _ = await lookup.Lookup( Seed1, 8, - (_, _) => Task.FromResult(null), + (_, _) => Task.FromResult(null), token); health.DidNotReceive().OnIncomingMessageFrom(Seed1); } + [Test] + [CancelAfter(10000)] + public async Task Lookup_should_record_peer_failure_on_find_neighbour_timeout(CancellationToken token) + { + (LookupKNearestNeighbour lookup, _, INodeHealthTracker health) = + CreateLookup(1, TimeSpan.FromMilliseconds(50), [Seed1]); + + _ = await lookup.Lookup( + Seed1, + 8, + async (_, t) => + { + await Task.Delay(Timeout.Infinite, t); + return null; + }, + token); + + health.Received().OnRequestFailed(Seed1); + } + [TestCase(1)] [TestCase(3)] [CancelAfter(10000)] public async Task Lookup_should_return_results_with_different_alpha(int alpha, CancellationToken token) { - (LookupKNearestNeighbour lookup, _, _) = + (LookupKNearestNeighbour lookup, _, _) = CreateLookup(alpha, TimeSpan.FromSeconds(10), [Seed1, Seed2, Seed3]); - Dictionary neighbours = new() + Dictionary neighbours = new() { [Seed1] = [N1], [Seed2] = [N2], [Seed3] = [], }; - ValueHash256[] result = await lookup.Lookup( + int[] result = await lookup.Lookup( Self, 8, - (node, _) => Task.FromResult(neighbours.GetValueOrDefault(node, [])), + (node, _) => Task.FromResult(neighbours.GetValueOrDefault(node, [])), token); Assert.That(result, Is.Not.Empty); } + + [Test] + [CancelAfter(10000)] + public async Task Lookup_nodes_should_stream_routing_table_nodes_before_network_lookup_finishes(CancellationToken token) + { + (LookupKNearestNeighbour lookup, _, _) = + CreateLookup(1, TimeSpan.FromSeconds(10), [Seed1]); + TaskCompletionSource requestStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await using IAsyncEnumerator enumerator = lookup.LookupNodes( + Self, + 8, + async (_, findToken) => + { + requestStarted.SetResult(); + await Task.Delay(Timeout.Infinite, findToken); + return []; + }, + token).GetAsyncEnumerator(token); + + Assert.That(await enumerator.MoveNextAsync(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo(Seed1)); + await requestStarted.Task.WaitAsync(token); + } + + [Test] + [CancelAfter(10000)] + public async Task Lookup_nodes_should_stop_when_enough_candidates_are_streamed(CancellationToken token) + { + (LookupKNearestNeighbour lookup, _, _) = + CreateLookup(1, TimeSpan.FromSeconds(10), [Seed1, Seed2]); + int requests = 0; + + List result = await lookup.LookupNodes( + Self, + 1, + (_, _) => + { + requests++; + return Task.FromResult([]); + }, + token).ToListAsync(token); + + Assert.That(result, Is.EqualTo(new[] { Seed1 })); + Assert.That(requests, Is.Zero); + } + + [Test] + [CancelAfter(10000)] + public async Task Lookup_should_drain_cancelled_workers_before_returning(CancellationToken token) + { + (LookupKNearestNeighbour lookup, _, _) = + CreateLookup(2, TimeSpan.FromSeconds(10), [Seed1, Seed2, Seed3, N1]); + TaskCompletionSource cancelledWorkerDrained = new(TaskCreationOptions.RunContinuationsAsynchronously); + + _ = await lookup.Lookup( + Self, + 1, + async (node, findToken) => + { + if (node != Seed1) + { + return []; + } + + try + { + await Task.Delay(Timeout.Infinite, findToken); + return []; + } + catch (OperationCanceledException) + { + await Task.Delay(100, CancellationToken.None); + cancelledWorkerDrained.SetResult(); + throw; + } + }, + token); + + Assert.That(cancelledWorkerDrained.Task.IsCompleted, Is.True); + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/NodeHealthTrackerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/NodeHealthTrackerTests.cs index b748eb61d1ad..1474706a38b4 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/NodeHealthTrackerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/NodeHealthTrackerTests.cs @@ -5,9 +5,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Nethermind.Core.Crypto; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Kademlia; using NSubstitute; using NUnit.Framework; @@ -15,41 +13,42 @@ namespace Nethermind.Network.Discovery.Test.Kademlia; public class NodeHealthTrackerTests { - private const string Self = "self"; - private const string Remote = "remote"; - private const string Stale = "stale"; + private const int Self = 0; + private const int Remote = 1; + private const int Stale = 2; - private static (NodeHealthTracker Tracker, RoutingTableStub Routing, IKademliaMessageSender Sender) CreateTracker( - string? toRefresh = null, + private static (NodeHealthTracker Tracker, RoutingTableStub Routing, IKademliaMessageSender Sender) CreateTracker( + int? toRefresh = null, int failureThreshold = 5, - IKademliaMessageSender? sender = null) + TimeSpan? refreshPingTimeout = null, + IKademliaMessageSender? sender = null) { - RoutingTableStub routing = new() { ToRefresh = toRefresh ?? string.Empty }; - sender ??= Substitute.For>(); - KademliaConfig config = new() + RoutingTableStub routing = new() { ToRefresh = toRefresh }; + sender ??= Substitute.For>(); + KademliaConfig config = new() { CurrentNodeId = Self, NodeRequestFailureThreshold = failureThreshold, }; + if (refreshPingTimeout is { } timeout) config.RefreshPingTimeout = timeout; - NodeHealthTracker tracker = new( + NodeHealthTracker tracker = new( config, routing, - StringNodeHashProvider.Instance, - sender, - LimboLogs.Instance); + IntNodeHashProvider.Instance, + sender); return (tracker, routing, sender); } [Test] public void OnIncomingMessageFrom_ShouldRefreshSelfWithSelfNode_WhenFullBucketSelectsSelf() { - (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker(toRefresh: Self); + (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker(toRefresh: Self); tracker.OnIncomingMessageFrom(Remote); Assert.That(routing.AddCalls, Has.Count.EqualTo(2)); - Assert.That(routing.AddCalls[1].Hash, Is.EqualTo(ValueKeccak.Compute(Self))); + Assert.That(routing.AddCalls[1].Hash, Is.EqualTo(Self)); Assert.That(routing.AddCalls[1].Node, Is.EqualTo(Self)); } @@ -57,49 +56,92 @@ public void OnIncomingMessageFrom_ShouldRefreshSelfWithSelfNode_WhenFullBucketSe [CancelAfter(10000)] public async Task TryRefresh_ShouldRemoveStaleNode_WhenPingTimesOut(CancellationToken token) { - IKademliaMessageSender sender = Substitute.For>(); + IKademliaMessageSender sender = Substitute.For>(); sender.Ping(Stale, Arg.Any()) .Returns(false); - (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker( + (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker( toRefresh: Stale, sender: sender); tracker.OnIncomingMessageFrom(Remote); - await AssertEventuallyAsync(() => routing.RemoveCalls.Contains(ValueKeccak.Compute(Stale)), token); - await sender.Received(1).Ping(Stale, Arg.Is(t => !t.CanBeCanceled)); + await AssertEventuallyAsync(() => routing.RemoveCalls.Contains(Stale), token); } [Test] [CancelAfter(10000)] public async Task TryRefresh_ShouldKeepNode_WhenPingSucceeds(CancellationToken token) { - IKademliaMessageSender sender = Substitute.For>(); + IKademliaMessageSender sender = Substitute.For>(); sender.Ping(Stale, Arg.Any()).Returns(true); - (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker( + (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker( toRefresh: Stale, sender: sender); tracker.OnIncomingMessageFrom(Remote); - ValueHash256 staleHash = ValueKeccak.Compute(Stale); - await AssertEventuallyAsync(() => routing.HasAddedNode(staleHash), token); - Assert.That(routing.RemoveCalls, Does.Not.Contain(staleHash)); + await AssertEventuallyAsync(() => routing.HasAddedNode(Stale), token); + Assert.That(routing.RemoveCalls, Does.Not.Contain(Stale)); + } + + [TestCase(false)] + [TestCase(true)] + [CancelAfter(10000)] + public async Task Dispose_ShouldCancelActiveRefreshWithoutRemovingNode(bool asyncDispose, CancellationToken token) + { + TaskCompletionSource pingStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource pingCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + IKademliaMessageSender sender = Substitute.For>(); + sender.Ping(Stale, Arg.Any()).Returns(async call => + { + CancellationToken pingToken = call.Arg(); + pingStarted.SetResult(); + try + { + await Task.Delay(Timeout.Infinite, pingToken); + return true; + } + catch (OperationCanceledException) + { + pingCancelled.SetResult(); + throw; + } + }); + + (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker( + toRefresh: Stale, + refreshPingTimeout: TimeSpan.FromSeconds(10), + sender: sender); + + tracker.OnIncomingMessageFrom(Remote); + await pingStarted.Task.WaitAsync(token); + + if (asyncDispose) + { + await tracker.DisposeAsync(); + } + else + { + tracker.Dispose(); + } + + await pingCancelled.Task.WaitAsync(token); + Assert.That(routing.RemoveCalls, Does.Not.Contain(Stale)); } [Test] public void OnRequestFailed_ShouldClearFailureCount_WhenNodeIsRemoved() { - (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker(failureThreshold: 1); + (NodeHealthTracker tracker, RoutingTableStub routing, _) = CreateTracker(failureThreshold: 1); tracker.OnRequestFailed(Remote); tracker.OnRequestFailed(Remote); tracker.OnRequestFailed(Remote); Assert.That(routing.RemoveCalls, Has.Count.EqualTo(1)); - Assert.That(routing.RemoveCalls[0], Is.EqualTo(ValueKeccak.Compute(Remote))); + Assert.That(routing.RemoveCalls[0], Is.EqualTo(Remote)); } private static async Task AssertEventuallyAsync(Func condition, CancellationToken token) @@ -112,38 +154,38 @@ private static async Task AssertEventuallyAsync(Func condition, Cancellati Assert.Fail("Condition not met within timeout."); } - private sealed class StringNodeHashProvider : INodeHashProvider - { - public static readonly StringNodeHashProvider Instance = new(); - public ValueHash256 GetHash(string node) => ValueKeccak.Compute(node); - } - - private sealed class RoutingTableStub : IRoutingTable + private sealed class RoutingTableStub : IRoutingTable { - public string ToRefresh { get; init; } = string.Empty; + public int? ToRefresh { get; init; } - public List<(ValueHash256 Hash, string Node)> AddCalls { get; } = []; + public List<(int Hash, int Node)> AddCalls { get; } = []; - public List RemoveCalls { get; } = []; + public List RemoveCalls { get; } = []; - public BucketAddResult TryAddOrRefresh(in ValueHash256 hash, string item, out string? toRefresh) + public BucketAddResult TryAddOrRefresh(in int hash, int item, out int toRefresh) { - lock (AddCalls) AddCalls.Add((hash, item)); - if (AddCalls.Count == 1) + bool isFirstAdd; + lock (AddCalls) + { + AddCalls.Add((hash, item)); + isFirstAdd = AddCalls.Count == 1; + } + + if (isFirstAdd && ToRefresh is not null) { - toRefresh = ToRefresh; + toRefresh = ToRefresh.Value; return BucketAddResult.Full; } - toRefresh = null; + toRefresh = default; return BucketAddResult.Refreshed; } - public bool HasAddedNode(ValueHash256 hash) + public bool HasAddedNode(int hash) { lock (AddCalls) { - foreach ((ValueHash256 h, string _) in AddCalls) + foreach ((int h, int _) in AddCalls) { if (h == hash) return true; } @@ -151,36 +193,45 @@ public bool HasAddedNode(ValueHash256 hash) return false; } - public bool Remove(in ValueHash256 hash) + public bool Remove(in int hash) { lock (RemoveCalls) RemoveCalls.Add(hash); return true; } - public string[] GetKNearestNeighbour(ValueHash256 hash, ValueHash256? exclude = null, bool excludeSelf = false) => + public int[] GetKNearestNeighbour(int hash, bool excludeSelf = false) => + throw new NotSupportedException(); + + public int[] GetKNearestNeighbourExcluding(int hash, int exclude, bool excludeSelf = false) => throw new NotSupportedException(); - public string[] GetAllAtDistance(int i) => throw new NotSupportedException(); + public int[] GetAllAtDistance(int i) => throw new NotSupportedException(); - public IEnumerable<(ValueHash256 Prefix, int Distance, KBucket Bucket)> IterateBuckets() => + public IEnumerable> IterateBuckets() => throw new NotSupportedException(); - public string? GetByHash(ValueHash256 nodeId) => throw new NotSupportedException(); + public int GetByHash(int nodeId) => throw new NotSupportedException(); public void LogDebugInfo() => throw new NotSupportedException(); - public event EventHandler? OnNodeAdded + public event EventHandler? OnNodeAdded { add { } remove { } } - public event EventHandler? OnNodeRemoved + public event EventHandler? OnNodeRemoved { add { } remove { } } - public int Size => AddCalls.Count; + public int Size + { + get + { + lock (AddCalls) return AddCalls.Count; + } + } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RandomWalkKademliaDiscoveryTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RandomWalkKademliaDiscoveryTests.cs new file mode 100644 index 000000000000..9bd0b311824e --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RandomWalkKademliaDiscoveryTests.cs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Nethermind.Kademlia; +using Nethermind.Logging; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +public class RandomWalkKademliaDiscoveryTests +{ + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_should_stream_nodes_from_random_lookup(CancellationToken token) + { + TestKademlia kademlia = new(); + RandomWalkKademliaDiscovery discovery = new( + kademlia, + IntKeyOperator.Instance, + Int32KademliaDistance.Instance, + new KademliaConfig { CurrentNodeId = 0 }, + LimboLogs.Instance); + + List nodes = await discovery.DiscoverNodes(1, 2, token).Take(2).ToListAsync(token); + + using (Assert.EnterMultipleScope()) + { + Assert.That(nodes, Is.EqualTo(new[] { 1, 2 })); + Assert.That(kademlia.LookupNodesCalls, Is.EqualTo(1)); + Assert.That(kademlia.LastMaxResults, Is.EqualTo(2)); + } + } + + private sealed class TestKademlia : IKademlia + { + public event EventHandler? OnNodeAdded { add { } remove { } } + public event EventHandler? OnNodeRemoved { add { } remove { } } + + public int LookupNodesCalls { get; private set; } + public int? LastMaxResults { get; private set; } + + public void AddOrRefresh(int node) => throw new NotSupportedException(); + + public void Remove(int node) => throw new NotSupportedException(); + + public Task Run(CancellationToken token) => throw new NotSupportedException(); + + public Task Bootstrap(CancellationToken token) => throw new NotSupportedException(); + + public Task LookupNodesClosest(int key, CancellationToken token, int? k = null) => throw new NotSupportedException(); + + public IAsyncEnumerable LookupNodes(int key, CancellationToken token, int? maxResults = null) + { + LookupNodesCalls++; + LastMaxResults = maxResults; + return CreateAsyncEnumerable(1, 2); + } + + public int[] GetKNeighbour(int target, int excluding = 0, bool excludeSelf = false) => throw new NotSupportedException(); + + public int[] GetAllAtDistance(int distance) => throw new NotSupportedException(); + + public IEnumerable IterateNodes() => throw new NotSupportedException(); + } + + private sealed class IntKeyOperator : IKeyOperator + { + public static IntKeyOperator Instance { get; } = new(); + + public int GetKey(int node) => node; + + public int GetKeyHash(int key) => key; + + public int CreateRandomKeyAtDistance(int nodePrefix, int depth) => depth; + } + + private static async IAsyncEnumerable CreateAsyncEnumerable(params IEnumerable items) + { + foreach (T item in items) + { + await Task.Yield(); + yield return item; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RecentNodeFilterTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RecentNodeFilterTests.cs new file mode 100644 index 000000000000..a9d40a31c331 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/RecentNodeFilterTests.cs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Network.Discovery.Kademlia; +using NUnit.Framework; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +public class RecentNodeFilterTests +{ + [TestCase(0, 64)] + [TestCase(4, 1024)] + [TestCase(16, 4096)] + [TestCase(160, 4096)] + public void GetLimit_should_cap_large_bucket_multiplier(int bucketSize, int expected) + => Assert.That(RecentNodeFilter.GetLimit(bucketSize, maxDistance: 256, minimumCount: 64), Is.EqualTo(expected)); + + [Test] + public void TryReserve_should_reject_recent_node_until_released() + { + RecentNodeFilter filter = new(2); + + Assert.That(filter.TryReserve("a"), Is.True); + Assert.That(filter.TryReserve("a"), Is.False); + + filter.Release("a"); + + Assert.That(filter.TryReserve("a"), Is.True); + } + + [Test] + public void TryReserve_should_evict_oldest_active_node_when_limit_is_exceeded() + { + RecentNodeFilter filter = new(2); + + Assert.That(filter.TryReserve("a"), Is.True); + Assert.That(filter.TryReserve("b"), Is.True); + Assert.That(filter.TryReserve("c"), Is.True); + + Assert.That(filter.TryReserve("a"), Is.True); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/ValueHashKeyOperator.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/ValueHashKeyOperator.cs new file mode 100644 index 000000000000..70936ace0655 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Kademlia/ValueHashKeyOperator.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Kademlia; + +namespace Nethermind.Network.Discovery.Test.Kademlia; + +internal sealed class ValueHashKeyOperator(Func getKey) : IKeyOperator +{ + public static Hash256 ToHash(ValueHash256 hash) => hash.ToHash256(); + + public static ValueHash256 ToValueHash(Hash256 hash) => hash.ValueHash256; + + public ValueHash256 GetKey(TNode node) => getKey(node); + + public Hash256 GetKeyHash(ValueHash256 key) => ToHash(key); + + public ValueHash256 CreateRandomKeyAtDistance(Hash256 nodePrefix, int depth) + => ToValueHash(Hash256KademliaDistance.Instance.GetRandomHashAtDistance(nodePrefix, depth)); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/KademliaDiscoveryAppTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/KademliaDiscoveryAppTests.cs new file mode 100644 index 000000000000..ab331a5205de --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/KademliaDiscoveryAppTests.cs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using DotNetty.Transport.Channels; +using Nethermind.Config; +using Nethermind.Core.Test.Modules; +using Nethermind.Logging; +using Nethermind.Network.Config; +using NUnit.Framework; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Nethermind.Network.Discovery.Test; + +[Parallelizable(ParallelScope.Self)] +[TestFixture] +public class KademliaDiscoveryAppTests +{ + [Test] + public async Task DisposeAsync_StopsRunningDiscovery() + { + TestKademliaDiscoveryApp app = new(); + + await app.StartAsync(); + await app.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + await app.DisposeAsync(); + await app.DisposeAsync(); + + Assert.That(app.Stopped.Task.IsCompletedSuccessfully, Is.True); + Assert.That(app.StopAsyncCoreCalls, Is.EqualTo(1)); + Assert.That(app.DisposeAsyncCoreCalls, Is.EqualTo(1)); + } + + [Test] + public async Task DisposeAsync_DisposesCore_WhenStopFails() + { + TestKademliaDiscoveryApp app = new(throwOnStop: true); + + await app.StartAsync(); + await app.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + InvalidOperationException? exception = Assert.ThrowsAsync(async () => await app.DisposeAsync()); + + Assert.That(exception?.Message, Is.EqualTo("Stop failed")); + Assert.That(app.Stopped.Task.IsCompletedSuccessfully, Is.True); + Assert.That(app.StopAsyncCoreCalls, Is.EqualTo(1)); + Assert.That(app.DisposeAsyncCoreCalls, Is.EqualTo(1)); + } + + private sealed class TestKademliaDiscoveryApp(bool throwOnStop = false) : KademliaDiscoveryApp( + "test discovery", + new NetworkConfig { ExternalIp = "127.0.0.1" }, + new FixedIpResolver(new NetworkConfig { ExternalIp = "127.0.0.1" }), + new ProcessExitSource(CancellationToken.None), + LimboLogs.Instance.GetClassLogger()) + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Stopped { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int StopAsyncCoreCalls { get; private set; } + + public int DisposeAsyncCoreCalls { get; private set; } + + public override void InitializeChannel(IChannel channel) + { + } + + protected override async Task RunDiscoveryAsync(CancellationToken cancellationToken) + { + Started.SetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + Stopped.SetResult(); + } + } + + protected override Task StopAsyncCore() + { + StopAsyncCoreCalls++; + if (throwOnStop) + { + throw new InvalidOperationException("Stop failed"); + } + + return Task.CompletedTask; + } + + protected override ValueTask DisposeAsyncCore() + { + DisposeAsyncCoreCalls++; + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/Nethermind.Network.Discovery.Test.csproj b/src/Nethermind/Nethermind.Network.Discovery.Test/Nethermind.Network.Discovery.Test.csproj index c01aa23deac4..a55ed7e84f78 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/Nethermind.Network.Discovery.Test.csproj +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/Nethermind.Network.Discovery.Test.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryV5HandlerTests.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryV5HandlerTests.cs index a7a8ce2a9b28..020eef9b710b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryV5HandlerTests.cs +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/NettyDiscoveryV5HandlerTests.cs @@ -1,21 +1,22 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; using System.Collections.Generic; using System.Linq; using System.Net; -using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using DotNetty.Buffers; +using DotNetty.Common.Utilities; using DotNetty.Transport.Channels; using DotNetty.Transport.Channels.Embedded; using DotNetty.Transport.Channels.Sockets; using Nethermind.Logging; +using Nethermind.Network.Discovery.Discv5; using Nethermind.Serialization.Rlp; using NSubstitute; using NUnit.Framework; -using Nethermind.Network.Discovery.Discv5; namespace Nethermind.Network.Discovery.Test { @@ -43,12 +44,41 @@ public async Task ForwardsSentMessageToChannel() byte[] data = [1, 2, 3]; IPEndPoint to = IPEndPoint.Parse("127.0.0.1:10001"); - await _handler.SendAsync(data, to); + await _handler.SendAsync(data, to, CancellationToken.None); DatagramPacket packet = _channel.ReadOutbound(); - Assert.That(packet, Is.Not.Null); - Assert.That(packet.Content.ReadAllBytesAsArray(), Is.EqualTo(data)); - Assert.That(packet.Recipient, Is.EqualTo(to)); + try + { + Assert.That(packet, Is.Not.Null); + Assert.That(packet.Content.ReadAllBytesAsArray(), Is.EqualTo(data)); + Assert.That(packet.Recipient, Is.EqualTo(to)); + } + finally + { + ReferenceCountUtil.Release(packet); + } + } + + [Test] + public void DoesNotSendWhenTokenIsAlreadyCanceled() + { + byte[] data = [1, 2, 3]; + IPEndPoint to = IPEndPoint.Parse("127.0.0.1:10001"); + using CancellationTokenSource cancellationSource = new(); + cancellationSource.Cancel(); + + Assert.ThrowsAsync( + async () => await _handler.SendAsync(data, to, cancellationSource.Token)); + + DatagramPacket? packet = _channel.ReadOutbound(); + try + { + Assert.That(packet, Is.Null); + } + finally + { + ReferenceCountUtil.Release(packet); + } } [Test] @@ -59,19 +89,59 @@ public async Task ForwardsReceivedMessageToReader() IPEndPoint to = IPEndPoint.Parse("127.0.0.1:10001"); using CancellationTokenSource cancellationSource = new(10_000); - IAsyncEnumerator enumerator = _handler + await using IAsyncEnumerator enumerator = _handler .ReadMessagesAsync(cancellationSource.Token) .GetAsyncEnumerator(cancellationSource.Token); + ValueTask readTask = enumerator.MoveNextAsync(); IChannelHandlerContext ctx = Substitute.For(); _handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer(data), from, to)); - Assert.That((await enumerator.MoveNextAsync()), Is.True); - UdpReceiveResult forwardedPacket = enumerator.Current; + Assert.That(await readTask, Is.True); + PooledUdpReceiveResult forwardedPacket = enumerator.Current; + + try + { + Assert.That(forwardedPacket.Buffer.ToArray(), Is.EqualTo(data)); + Assert.That(forwardedPacket.RemoteEndPoint, Is.EqualTo(from)); + } + finally + { + forwardedPacket.Dispose(); + } + } + + [Test] + public async Task MapsIpv4MappedIpv6SenderToIpv4() + { + byte[] data = [1, 2, 3]; + IPEndPoint from = new(IPAddress.Parse("::ffff:127.0.0.1"), 10000); + IPEndPoint expectedFrom = IPEndPoint.Parse("127.0.0.1:10000"); + IPEndPoint to = IPEndPoint.Parse("127.0.0.1:10001"); - Assert.That(forwardedPacket.Buffer, Is.EqualTo(data)); - Assert.That(forwardedPacket.RemoteEndPoint, Is.EqualTo(from)); + using CancellationTokenSource cancellationSource = new(10_000); + await using IAsyncEnumerator enumerator = _handler + .ReadMessagesAsync(cancellationSource.Token) + .GetAsyncEnumerator(cancellationSource.Token); + ValueTask readTask = enumerator.MoveNextAsync(); + + IChannelHandlerContext ctx = Substitute.For(); + + _handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer(data), from, to)); + + Assert.That(await readTask, Is.True); + PooledUdpReceiveResult forwardedPacket = enumerator.Current; + + try + { + Assert.That(forwardedPacket.Buffer.ToArray(), Is.EqualTo(data)); + Assert.That(forwardedPacket.RemoteEndPoint, Is.EqualTo(expectedFrom)); + } + finally + { + forwardedPacket.Dispose(); + } } [TestCase(0)] @@ -84,9 +154,10 @@ public async Task SkipsMessagesOfInvalidSize(int size) IPEndPoint to = IPEndPoint.Parse("127.0.0.1:10001"); using CancellationTokenSource cancellationSource = new(10_000); - IAsyncEnumerator enumerator = _handler + await using IAsyncEnumerator enumerator = _handler .ReadMessagesAsync(cancellationSource.Token) .GetAsyncEnumerator(cancellationSource.Token); + ValueTask readTask = enumerator.MoveNextAsync(); IChannelHandlerContext ctx = Substitute.For(); @@ -95,9 +166,32 @@ public async Task SkipsMessagesOfInvalidSize(int size) _handler.ChannelRead(ctx, new DatagramPacket(Unpooled.WrappedBuffer((byte[])invalidData.Clone()), from, to)); _handler.Close(); - Assert.That((await enumerator.MoveNextAsync()), Is.True); - Assert.That(enumerator.Current.Buffer, Is.EqualTo(data)); - Assert.That((await enumerator.MoveNextAsync()), Is.False); + Assert.That(await readTask, Is.True); + PooledUdpReceiveResult forwardedPacket = enumerator.Current; + try + { + Assert.That(forwardedPacket.Buffer.ToArray(), Is.EqualTo(data)); + } + finally + { + forwardedPacket.Dispose(); + } + + Assert.That(await enumerator.MoveNextAsync(), Is.False); + } + + [Test] + public async Task ChannelInactiveStopsReader() + { + using CancellationTokenSource cancellationSource = new(10_000); + await using IAsyncEnumerator enumerator = _handler + .ReadMessagesAsync(cancellationSource.Token) + .GetAsyncEnumerator(cancellationSource.Token); + ValueTask readTask = enumerator.MoveNextAsync(); + + _handler.ChannelInactive(Substitute.For()); + + Assert.That(await readTask.AsTask().WaitAsync(cancellationSource.Token), Is.False); } } } diff --git a/src/Nethermind/Nethermind.Network.Discovery.Test/TestEnrBuilder.cs b/src/Nethermind/Nethermind.Network.Discovery.Test/TestEnrBuilder.cs new file mode 100644 index 000000000000..7b2863d43bea --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery.Test/TestEnrBuilder.cs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Net; +using System.Net.Sockets; +using Nethermind.Crypto; +using Nethermind.Network.Enr; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Test; + +internal static class TestEnrBuilder +{ + public static NodeRecord BuildSigned( + PrivateKey privateKey, + IPAddress? ipAddress = null, + int? tcpPort = 30303, + int? udpPort = 30303, + bool useUdp6 = true, + ulong enrSequence = 1, + Action? configureExtras = null) + { + IPAddress ip = ipAddress ?? IPAddress.Loopback; + bool isIpv6 = ip.AddressFamily == AddressFamily.InterNetworkV6; + NodeRecord enr = new(); + if (isIpv6) + { + enr.SetEntry(new Ip6Entry(ip)); + } + else + { + enr.SetEntry(new IpEntry(ip)); + } + enr.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + if (tcpPort is { } tcp) + { + enr.SetEntry(new TcpEntry(tcp)); + } + if (udpPort is { } udp) + { + if (isIpv6 && useUdp6) + { + enr.SetEntry(new Udp6Entry(udp)); + } + else + { + enr.SetEntry(new UdpEntry(udp)); + } + } + configureExtras?.Invoke(enr); + enr.EnrSequence = enrSequence; + Sign(enr, privateKey); + return enr; + } + + public static NodeRecord BuildSignedWithoutEndpoint(PrivateKey privateKey, ulong enrSequence = 1) + { + NodeRecord enr = new(); + enr.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + enr.EnrSequence = enrSequence; + Sign(enr, privateKey); + return enr; + } + + private static void Sign(NodeRecord enr, PrivateKey privateKey) => new NodeRecordSigner(new EthereumEcdsa(0), privateKey).Sign(enr); +} + +internal sealed class TestEth2Entry() : EnrContentEntry([1, 2, 3, 4]) +{ + public override string Key => EnrContentKey.Eth2; + + protected override int GetRlpLengthOfValue() => Rlp.LengthOf(Value); + + protected override void EncodeValue(RlpStream rlpStream) => rlpStream.Encode(Value); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/AddressBurstLimiter.cs b/src/Nethermind/Nethermind.Network.Discovery/AddressBurstLimiter.cs new file mode 100644 index 000000000000..2768ed561a81 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/AddressBurstLimiter.cs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; + +namespace Nethermind.Network.Discovery; + +internal sealed class AddressBurstLimiter +{ + private readonly NodeFilter[] _filters; + + public AddressBurstLimiter(int burstPerAddress, int filterSize, TimeSpan window) + { + _filters = new NodeFilter[Math.Max(1, burstPerAddress)]; + for (int i = 0; i < _filters.Length; i++) + { + _filters[i] = NodeFilter.CreateExact(Math.Max(1, filterSize), window); + } + } + + public AddressBurstLimiter(NodeFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + + _filters = [filter]; + } + + public bool TryAccept(IPAddress address) + { + NodeFilter[] filters = _filters; + for (int i = 0; i < filters.Length; i++) + { + if (filters[i].TryAccept(address, exactOnly: true)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/CompositeDiscoveryApp.cs b/src/Nethermind/Nethermind.Network.Discovery/CompositeDiscoveryApp.cs index 3b383a7e2e43..d854c71b15dc 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/CompositeDiscoveryApp.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/CompositeDiscoveryApp.cs @@ -10,6 +10,7 @@ using Nethermind.Core.ServiceStopper; using Nethermind.Logging; using Nethermind.Network.Config; +using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.Discovery.Discv5; using Nethermind.Serialization.Rlp; using Nethermind.Stats.Model; @@ -19,7 +20,7 @@ namespace Nethermind.Network.Discovery; /// /// Combines several protocol versions under a single implementation. /// -public class CompositeDiscoveryApp : IDiscoveryApp +public sealed class CompositeDiscoveryApp : IDiscoveryApp { private readonly INetworkConfig _networkConfig; private readonly IConnectionsPool _connections; diff --git a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryApp.cs b/src/Nethermind/Nethermind.Network.Discovery/DiscoveryApp.cs deleted file mode 100644 index 49fcd53bba4a..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryApp.cs +++ /dev/null @@ -1,262 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Autofac; -using DotNetty.Handlers.Logging; -using DotNetty.Transport.Channels; -using Nethermind.Config; -using Nethermind.Core; -using Nethermind.Core.Crypto; -using Nethermind.Core.ServiceStopper; -using Nethermind.Logging; -using Nethermind.Network.Config; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats.Model; -using LogLevel = DotNetty.Handlers.Logging.LogLevel; - -namespace Nethermind.Network.Discovery; - -public class DiscoveryApp : IDiscoveryApp, IAsyncDisposable -{ - private readonly ILogger _logger; - private readonly INetworkConfig _networkConfig; - private readonly IIPResolver _ipResolver; - private readonly IKademliaNodeSource _kademliaNodeSource; - private readonly DiscoveryPersistenceManager _persistenceManager; - private readonly IKademliaDiscv4Adapter _discv4Adapter; - private readonly IKademlia _kademlia; - private readonly Func _discoveryHandlerFactory; - private readonly ILifetimeScope _discv4Services; - private readonly CancellationTokenSource _stopCts; - - private NettyDiscoveryHandler? _discoveryHandler; - private Task? _runningTask; - - public DiscoveryApp( - ILifetimeScope rootScope, - IEnode enode, - INetworkConfig networkConfig, - IDiscoveryConfig discoveryConfig, - IIPResolver ipResolver, - IProcessExitSource processExitSource, - ILogManager logManager, - Action? configureDiscv4Services = null) - { - _logger = logManager.GetClassLogger(); - _networkConfig = networkConfig; - _ipResolver = ipResolver; - _stopCts = CancellationTokenSource.CreateLinkedTokenSource(processExitSource.Token); - - List bootNodes = []; - NetworkNode[] bootnodes = networkConfig.Bootnodes; - if (bootnodes.Length == 0) - { - if (_logger.IsWarn) _logger.Warn("No bootnodes specified in configuration"); - } - - for (int i = 0; i < bootnodes.Length; i++) - { - NetworkNode bootnode = bootnodes[i]; - if (!bootnode.IsEnode) - { - if (_logger.IsTrace) _logger.Trace($"Ignoring ENR in discovery V4: {bootnode}"); - continue; - } - - if (bootnode.NodeId is null) - { - _logger.Warn($"Bootnode ignored because of missing node ID: {bootnode}"); - continue; - } - - bootNodes.Add(new(bootnode.NodeId, bootnode.Host, bootnode.Port)); - } - - _discv4Services = rootScope.BeginLifetimeScope( - (builder) => - { - builder - .AddModule(new DiscV4KademliaModule(enode, networkConfig, bootNodes)) - .AddSingleton(); - - configureDiscv4Services?.Invoke(builder); - }); - - (_kademliaNodeSource, _persistenceManager, _discv4Adapter, _kademlia, _discoveryHandlerFactory) = _discv4Services.Resolve(); - _kademlia.OnNodeRemoved += OnKademliaNodeRemoved; - } - - /// - /// Just a small class to make resolve easier - /// - private record DiscV4Services( - IKademliaNodeSource NodeSource, - DiscoveryPersistenceManager PersistenceManager, - IKademliaDiscv4Adapter Discv4Adapter, - IKademlia Kademlia, - Func NettyDiscoveryHandlerFactory - ) - { - } - - public async Task StartAsync() - { - try - { - await Initialize(); - } - catch (Exception e) - { - _logger.Error("Error during discovery app start process", e); - throw; - } - } - - public async Task StopAsync() - { - DetachEventHandlers(); - - try - { - await _stopCts.CancelAsync(); - } - catch (ObjectDisposedException) - { - } - - try - { - if (_runningTask is not null) - { - await _runningTask; - } - } - catch (OperationCanceledException) - { - } - catch (Exception e) - { - if (_logger.IsError) _logger.Error("Error in discovery task", e); - } - - _stopCts.Dispose(); - - if (_logger.IsInfo) _logger.Info("Discovery shutdown complete. Please wait for all components to close"); - } - - private void DetachEventHandlers() - { - try - { - _discoveryHandler?.OnChannelActivated -= OnChannelActivated; - } - catch (Exception e) - { - _logger.Error("Error during discovery cleanup", e); - } - } - - string IStoppableService.Description => "discv4"; - - public void AddNodeToDiscovery(Node node) => _kademlia.AddOrRefresh(node); - - private async Task Initialize() - { - IIPResolver.NethermindIp ip = await _ipResolver.Resolve(); - if (_logger.IsDebug) - _logger.Debug($"Discovery : udp://{ip.ExternalIp}:{_networkConfig.DiscoveryPort}"); - ThisNodeInfo.AddInfo("Discovery :", $"udp://{ip.ExternalIp}:{_networkConfig.DiscoveryPort}"); - } - - protected virtual NettyDiscoveryHandler CreateDiscoveryHandler(IChannel channel) - { - NettyDiscoveryHandler discoveryHandler = _discoveryHandlerFactory(channel); - _discv4Adapter.MsgSender = discoveryHandler; - return discoveryHandler; - } - - public void InitializeChannel(IChannel channel) - { - _discoveryHandler = CreateDiscoveryHandler(channel); - _discoveryHandler.OnChannelActivated += OnChannelActivated; - - channel.Pipeline - .AddLast(new LoggingHandler(LogLevel.INFO)) - .AddLast(_discoveryHandler); - } - - private void OnChannelActivated(object? sender, EventArgs e) - { - if (_logger.IsDebug) _logger.Debug("Activated discovery channel."); - - // Make sure this is non blocking code, otherwise netty will not process messages - // Explicitly use TaskScheduler.Default, otherwise it will use dotnetty's task scheduler which have a habit of - // not working sometimes. - if (_stopCts.IsCancellationRequested) return; - _runningTask = StartActivationAsync(_stopCts.Token); - } - - private async Task StartActivationAsync(CancellationToken cancellationToken) - { - const string faultMessage = "Cannot activate channel."; - - try - { - await Task.Factory.StartNew(static state => ((DiscoveryApp)state!).ActivateAsync(), this, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); - if (!cancellationToken.IsCancellationRequested && _logger.IsDebug) _logger.Debug("Discovery App initialized."); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - catch (Exception) - { - if (_logger.IsInfo) _logger.Info(faultMessage); - throw; - } - } - - private Task ActivateAsync() => ActivateAsync(_stopCts.Token); - - private async Task ActivateAsync(CancellationToken cancellationToken) - { - try - { - //Step 1 - read nodes and stats from db - await _persistenceManager.LoadPersistedNodes(cancellationToken); - - Task persistenceTask = _persistenceManager.RunDiscoveryPersistenceCommit(cancellationToken); - - try - { - // Step 2 - run the standard kademlia routine - await _kademlia.Run(cancellationToken); - } - finally - { - // Block until persistence is finished - await persistenceTask; - } - } - catch (OperationCanceledException) - { - if (_logger.IsInfo) _logger.Info("Discovery App stopped"); - } - catch (Exception e) - { - _logger.DebugError("Error during discovery initialization", e); - } - } - - public IAsyncEnumerable DiscoverNodes(CancellationToken token) => _kademliaNodeSource.DiscoverNodes(token); - - private void OnKademliaNodeRemoved(object? sender, Node node) => NodeRemoved?.Invoke(sender, new NodeEventArgs(node)); - - public event EventHandler? NodeRemoved; - - public async ValueTask DisposeAsync() - { - _kademlia.OnNodeRemoved -= OnKademliaNodeRemoved; - await _discv4Services.DisposeAsync(); - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryConnectionsPool.cs b/src/Nethermind/Nethermind.Network.Discovery/DiscoveryConnectionsPool.cs index bba175d6d6a4..5fedfd7d4210 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryConnectionsPool.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/DiscoveryConnectionsPool.cs @@ -12,7 +12,7 @@ namespace Nethermind.Network.Discovery; /// Manages connections (Netty ) allocated for all Discovery protocol versions. /// /// Not thread-safe -public class DiscoveryConnectionsPool(ILogger logger, IIPResolver ipResolver, IDiscoveryConfig discoveryConfig) : IConnectionsPool +public sealed class DiscoveryConnectionsPool(ILogger logger, IIPResolver ipResolver, IDiscoveryConfig discoveryConfig) : IConnectionsPool { private readonly ILogger _logger = logger; private readonly IIPResolver _ipResolver = ipResolver; diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscV4KademliaModule.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscV4KademliaModule.cs deleted file mode 100644 index 9494749e2284..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscV4KademliaModule.cs +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Net; -using Autofac; -using Nethermind.Config; -using Nethermind.Core; -using Nethermind.Core.Crypto; -using Nethermind.Network.Config; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats.Model; - -namespace Nethermind.Network.Discovery.Discv4; - -/// -/// Specify the discv4 kademlia components. Mainly provide transport for . -/// Because kademlia can and probably will be reused outside of discv4, this module is meant to be added within a child -/// lifecycle in to prevent unexpected conflict. -/// -/// -/// -/// -public class DiscV4KademliaModule(IEnode enode, INetworkConfig networkConfig, IReadOnlyList bootNodes) : Module -{ - protected override void Load(ContainerBuilder builder) => builder - .AddSingleton() - - // This two class contains the actual `INodeSource` logic. As in finding nodes within the network. - .AddSingleton() - - // Some transport wiring. - .AddSingleton() - .Bind() - .AddSingleton() - - // Register the main kademlia module and integration - .AddModule(new KademliaModule()) - .Bind, IKademliaDiscv4Adapter>() - .AddSingleton, PublicKeyKeyOperator>() - .AddSingleton, IDiscoveryConfig>((discoveryConfig) => new KademliaConfig() - { - CurrentNodeId = new Node( - enode.PublicKey, - new IPEndPoint(enode.HostIp, networkConfig.DiscoveryPort), - isStatic: true), - KSize = discoveryConfig.BucketSize, - Alpha = discoveryConfig.Concurrency, - Beta = discoveryConfig.BitsPerHop, - - // Lookup wraps bonding plus FindNode; keep one SendNodeTimeout of slack for scheduling/rate-limit delay. - LookupFindNeighbourHardTimeout = TimeSpan.FromMilliseconds(discoveryConfig.PingTimeout + discoveryConfig.BondWaitTime + (2L * discoveryConfig.SendNodeTimeout)), - RefreshInterval = TimeSpan.FromMilliseconds(discoveryConfig.DiscoveryInterval), - BootNodes = bootNodes - }) - ; -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscoveryApp.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscoveryApp.cs new file mode 100644 index 000000000000..71a747e27e00 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/DiscoveryApp.cs @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Autofac; +using DotNetty.Transport.Channels; +using Nethermind.Config; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Config; +using Nethermind.Network.Discovery.Discv4.Kademlia; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; +using LogLevel = DotNetty.Handlers.Logging.LogLevel; + +namespace Nethermind.Network.Discovery.Discv4; + +public class DiscoveryApp : KademliaDiscoveryApp +{ + private readonly DiscoveryPersistenceManager _persistenceManager; + private readonly IKademliaAdapter _discv4Adapter; + private readonly Func _discoveryHandlerFactory; + private readonly ILifetimeScope _discv4Services; + + private NettyDiscoveryHandler? _discoveryHandler; + + public DiscoveryApp( + ILifetimeScope rootScope, + IEnode enode, + INetworkConfig networkConfig, + IDiscoveryConfig discoveryConfig, + IIPResolver ipResolver, + IProcessExitSource processExitSource, + ILogManager logManager, + Action? configureDiscv4Services = null) + : base("discv4", networkConfig, ipResolver, processExitSource, logManager.GetClassLogger()) + { + List bootNodes = CreateBootNodes(networkConfig.Bootnodes, Logger); + + _discv4Services = rootScope.BeginLifetimeScope( + (builder) => + { + Node currentNode = new(enode.PublicKey, enode.HostIp.ToString(), networkConfig.DiscoveryPort, true); + + builder + .AddModule(new KademliaModule(currentNode, bootNodes)) + .AddSingleton(); + + configureDiscv4Services?.Invoke(builder); + }); + + DiscV4Services services = _discv4Services.Resolve(); + _persistenceManager = services.PersistenceManager; + _discv4Adapter = services.Discv4Adapter; + _discoveryHandlerFactory = services.NettyDiscoveryHandlerFactory; + UseKademliaServices(services.NodeSource, services.Kademlia); + } + + internal static List CreateBootNodes(NetworkNode[] configuredBootnodes, ILogger logger) + { + List bootNodes = []; + if (configuredBootnodes.Length == 0) + { + if (logger.IsWarn) logger.Warn("No bootnodes specified in configuration"); + } + + for (int i = 0; i < configuredBootnodes.Length; i++) + { + NetworkNode bootnode = configuredBootnodes[i]; + if (!bootnode.IsEnode) + { + if (logger.IsTrace) logger.Trace($"Ignoring ENR in discovery V4: {bootnode}"); + continue; + } + + if (bootnode.NodeId is null) + { + logger.Warn($"Bootnode ignored because of missing node ID: {bootnode}"); + continue; + } + + bootNodes.Add(new(bootnode.NodeId, bootnode.Host, bootnode.DiscoveryPort)); + } + + return bootNodes; + } + + /// + /// Just a small class to make resolve easier + /// + private record DiscV4Services( + IKademliaNodeSource NodeSource, + DiscoveryPersistenceManager PersistenceManager, + IKademliaAdapter Discv4Adapter, + IKademlia Kademlia, + Func NettyDiscoveryHandlerFactory + ) + { + } + + protected override void DetachEventHandlers() + { + try + { + _discoveryHandler?.OnChannelActivated -= OnChannelActivated; + } + catch (Exception e) + { + Logger.Error("Error during discovery cleanup", e); + } + } + + protected virtual NettyDiscoveryHandler CreateDiscoveryHandler(IChannel channel) + { + NettyDiscoveryHandler discoveryHandler = _discoveryHandlerFactory(channel); + _discv4Adapter.MsgSender = discoveryHandler; + return discoveryHandler; + } + + public override void InitializeChannel(IChannel channel) + { + _discoveryHandler = CreateDiscoveryHandler(channel); + _discoveryHandler.OnChannelActivated += OnChannelActivated; + + channel.Pipeline + .AddLast(new DotNetty.Handlers.Logging.LoggingHandler(LogLevel.INFO)) + .AddLast(_discoveryHandler); + } + + protected override async Task RunDiscoveryAsync(CancellationToken cancellationToken) + { + //Step 1 - read nodes and stats from db + await _persistenceManager.LoadPersistedNodes(cancellationToken); + + Task persistenceTask = _persistenceManager.RunDiscoveryPersistenceCommit(cancellationToken); + + try + { + // Step 2 - run the standard kademlia routine + await Kademlia.Run(cancellationToken); + } + finally + { + // Block until persistence is finished + await persistenceTask; + } + } + + protected override ValueTask DisposeAsyncCore() => _discv4Services.DisposeAsync(); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/IDiscoveryMsgListener.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/IDiscoveryMsgListener.cs similarity index 65% rename from src/Nethermind/Nethermind.Network.Discovery/IDiscoveryMsgListener.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/IDiscoveryMsgListener.cs index 70db39491f26..6c188aee5f12 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/IDiscoveryMsgListener.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/IDiscoveryMsgListener.cs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; -namespace Nethermind.Network.Discovery; +namespace Nethermind.Network.Discovery.Discv4; public interface IDiscoveryMsgListener { diff --git a/src/Nethermind/Nethermind.Network.Discovery/IMsgSender.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/IMsgSender.cs similarity index 64% rename from src/Nethermind/Nethermind.Network.Discovery/IMsgSender.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/IMsgSender.cs index 1235dea5fef4..89eafc905cc0 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/IMsgSender.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/IMsgSender.cs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; -namespace Nethermind.Network.Discovery; +namespace Nethermind.Network.Discovery.Discv4; public interface IMsgSender { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/EnrResponseHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/EnrResponseHandler.cs similarity index 65% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/EnrResponseHandler.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/EnrResponseHandler.cs index c8608cc4b146..7c7d7c2ef8a8 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/EnrResponseHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/EnrResponseHandler.cs @@ -1,12 +1,11 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Core.Extensions; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; -public class EnrResponseHandler(EnrRequestMsg request) : ITaskCompleter +public sealed class EnrResponseHandler(EnrRequestMsg request) : ITaskCompleter { public TaskCompletionSource> TaskCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -14,6 +13,6 @@ public bool Handle(DiscoveryMsg msg) => !TaskCompletionSource.Task.IsCompleted && msg is EnrResponseMsg resp && request.Hash is { } expected - && Bytes.AreEqual(resp.RequestKeccak.Bytes, expected.Span) + && resp.RequestKeccak == expected && TaskCompletionSource.TrySetResult(DiscoveryResponse.From(resp)); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IMessageHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/IMessageHandler.cs similarity index 60% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/IMessageHandler.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/IMessageHandler.cs index e3be64a4b8cc..d96ead657f9b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IMessageHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/IMessageHandler.cs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; internal interface IMessageHandler { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/ITaskCompleter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/ITaskCompleter.cs similarity index 78% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/ITaskCompleter.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/ITaskCompleter.cs index c3a819c743cc..b281dd05a219 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/ITaskCompleter.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/ITaskCompleter.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; internal interface ITaskCompleter : IMessageHandler { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/NeighbourMsgHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/NeighbourMsgHandler.cs similarity index 53% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/NeighbourMsgHandler.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/NeighbourMsgHandler.cs index d433c67c1e80..bf888030602a 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/NeighbourMsgHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/NeighbourMsgHandler.cs @@ -1,38 +1,46 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; -public class NeighbourMsgHandler(int k) : ITaskCompleter +public sealed class NeighbourMsgHandler(int k) : ITaskCompleter { - private Node[] _current = []; + private readonly Lock _lock = new(); + private readonly Node[] _nodes = new Node[k]; + private int _count; public TaskCompletionSource> TaskCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); // The peer should send the two packet pretty much immediately. In any case, if the second packet is loss, its not a huge deal. private static readonly TimeSpan _secondRequestTimeout = TimeSpan.FromMilliseconds(100); - private bool _timeoutInitiated = false; + private int _timeoutInitiated; public bool Handle(DiscoveryMsg msg) { if (TaskCompletionSource.Task.IsCompleted) return false; NeighborsMsg neighborsMsg = (NeighborsMsg)msg; + bool isComplete; - while (true) + lock (_lock) { if (TaskCompletionSource.Task.IsCompleted) return false; - Node[] current = _current; - if (current.Length >= k || current.Length + neighborsMsg.Nodes.Count > k) return false; - if (Interlocked.CompareExchange(ref _current, [.. current, .. neighborsMsg.Nodes], current) == current) break; + if (_count >= k || _count + neighborsMsg.Nodes.Count > k) return false; + + for (int i = 0; i < neighborsMsg.Nodes.Count; i++) + { + _nodes[_count++] = neighborsMsg.Nodes[i]; + } + + isComplete = _count == k; } - if (_current.Length == k) + if (isComplete) { - return TaskCompletionSource.TrySetResult(DiscoveryResponse.From(_current)); + return TaskCompletionSource.TrySetResult(DiscoveryResponse.From(GetCurrentNodes())); } else { @@ -41,12 +49,25 @@ public bool Handle(DiscoveryMsg msg) async Task CompleteAfterDelay() { - if (Interlocked.CompareExchange(ref _timeoutInitiated, true, false)) return; + if (Interlocked.Exchange(ref _timeoutInitiated, 1) != 0) return; await Task.Delay(_secondRequestTimeout); - TaskCompletionSource.TrySetResult(DiscoveryResponse.From(_current)); + TaskCompletionSource.TrySetResult(DiscoveryResponse.From(GetCurrentNodes())); } } return !TaskCompletionSource.Task.IsCompleted; } + + private Node[] GetCurrentNodes() + { + lock (_lock) + { + if (_count == _nodes.Length) + { + return _nodes; + } + + return _nodes.AsSpan(0, _count).ToArray(); + } + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/PongMsgHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/PongMsgHandler.cs similarity index 63% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/PongMsgHandler.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/PongMsgHandler.cs index 92812e5d8775..f071ce01f1f5 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/PongMsgHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/Handlers/PongMsgHandler.cs @@ -1,18 +1,18 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Nethermind.Core.Extensions; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; -public class PongMsgHandler(PingMsg ping) : ITaskCompleter +public sealed class PongMsgHandler(PingMsg ping) : ITaskCompleter { public TaskCompletionSource> TaskCompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool Handle(DiscoveryMsg msg) => !TaskCompletionSource.Task.IsCompleted && msg is PongMsg pong - && Bytes.AreEqual(pong.PingMdc, ping.Mdc) + && ping.Mdc is { } expected + && pong.PingMdc == expected && TaskCompletionSource.TrySetResult(DiscoveryResponse.From(pong)); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaDiscv4Adapter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/IKademliaAdapter.cs similarity index 81% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaDiscv4Adapter.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/IKademliaAdapter.cs index 535244b680a5..39855cf7957b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaDiscv4Adapter.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/IKademliaAdapter.cs @@ -2,16 +2,16 @@ // SPDX-License-Identifier: LGPL-3.0-only using Nethermind.Core.Crypto; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia; /// /// Interfaces between and discv4. Largely handles the transport and session handling. /// -public interface IKademliaDiscv4Adapter : IKademliaMessageSender, IDiscoveryMsgListener, IAsyncDisposable +public interface IKademliaAdapter : IKademliaMessageSender, IDiscoveryMsgListener, IAsyncDisposable { /// /// Gets or sets the message sender used to send discovery messages. diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaDiscv4Adapter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaAdapter.cs similarity index 86% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaDiscv4Adapter.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaAdapter.cs index d6428c5dbe44..f3c54781f545 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaDiscv4Adapter.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaAdapter.cs @@ -6,16 +6,16 @@ using Nethermind.Core.Caching; using Nethermind.Core.Crypto; using Nethermind.Logging; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Network.Discovery.Messages; -using Nethermind.Serialization.Rlp; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv4.Kademlia.Handlers; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Stats; using Nethermind.Stats.Model; using NonBlocking; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Discv4.Kademlia; -public class KademliaDiscv4Adapter( +public sealed class KademliaAdapter( Lazy> kademlia, // Cyclic dependency Lazy> nodeHealthTracker, IDiscoveryConfig discoveryConfig, @@ -25,7 +25,7 @@ public class KademliaDiscv4Adapter( ITimestamper timestamper, IProcessExitSource processExitSource, ILogManager logManager -) : IKademliaDiscv4Adapter +) : IKademliaAdapter { private const int MaxNodesPerNeighborsMsg = 12; @@ -35,7 +35,7 @@ ILogManager logManager private readonly TimeSpan _expirationTime = TimeSpan.FromMilliseconds(discoveryConfig.MessageExpiryTime); private readonly TimeSpan _waitAfterPongDelay = TimeSpan.FromMilliseconds(discoveryConfig.BondWaitTime); - private readonly ILogger _logger = logManager.GetClassLogger(); + private readonly ILogger _logger = logManager.GetClassLogger(); private readonly RateLimiter _outboundRateLimiter = new(discoveryConfig.MaxOutgoingMessagePerSecond); public IMsgSender? MsgSender { get; set; } @@ -199,7 +199,7 @@ public async Task Ping(Node receiver, CancellationToken token) DiscoveryResponse response = await CallAndWaitForResponse(MsgType.Pong, new PongMsgHandler(msg), receiver, session, msg, _pingTimeout, token); if (!response.HasResponse) return false; - session.OnPongReceived(); + session.OnPongReceived(response.Value.FarAddress ?? receiver.Address); return true; } @@ -229,24 +229,30 @@ public async Task Ping(Node receiver, CancellationToken token) return response.HasResponse ? response.Value : null; } - private async Task HandleEnrRequest(Node node, NodeSession session, EnrRequestMsg msg, CancellationToken token) + private async Task HandleEnrRequest(Node node, NodeSession session, EnrRequestMsg msg, CancellationToken token) { - if (!session.HasReceivedPong) + if (!session.HasEndpointProof(node.Address)) { if (_logger.IsDebug) _logger.Debug($"Rejecting enr request from unbonded peer {node}"); - return; + return false; + } + + if (msg.Hash is not { } requestHash) + { + if (_logger.IsDebug) _logger.Debug($"Rejecting enr request without packet hash from {node}"); + return false; } - Rlp requestRlp = Rlp.Encode(Rlp.Encode(msg.ExpirationTime)); - await SendMessage(session, new EnrResponseMsg(node.Address, await nodeRecordProvider.GetCurrentAsync(token), Keccak.Compute(requestRlp.Bytes)), token); + await SendMessage(session, new EnrResponseMsg(node.Address, await nodeRecordProvider.GetCurrentAsync(token), new Hash256(requestHash)), token); + return true; } - private async Task HandleFindNode(Node node, NodeSession session, FindNodeMsg msg, CancellationToken token) + private async Task HandleFindNode(Node node, NodeSession session, FindNodeMsg msg, CancellationToken token) { - if (!session.HasReceivedPong) + if (!session.HasEndpointProof(node.Address)) { if (_logger.IsDebug) _logger.Debug($"Rejecting findNode request from unbonded peer {node}"); - return; + return false; } PublicKey publicKey = new(msg.SearchedNodeId); @@ -254,20 +260,28 @@ private async Task HandleFindNode(Node node, NodeSession session, FindNodeMsg ms if (nodes.Length == 0) { await SendMessage(session, new NeighborsMsg(node.Address, CalculateExpirationTime(), nodes), token); - return; + return true; } for (int i = 0; i < nodes.Length; i += MaxNodesPerNeighborsMsg) { int batchEnd = Math.Min(i + MaxNodesPerNeighborsMsg, nodes.Length); - await SendMessage(session, new NeighborsMsg(node.Address, CalculateExpirationTime(), nodes[i..batchEnd]), token); + await SendMessage(session, new NeighborsMsg(node.Address, CalculateExpirationTime(), new ArraySegment(nodes, i, batchEnd - i)), token); } + + return true; } private async Task HandlePing(Node node, NodeSession session, PingMsg ping, CancellationToken token) { if (_logger.IsTrace) _logger.Trace($"Receive ping from {node}"); - PongMsg msg = new(ping.FarAddress!, CalculateExpirationTime(), ping.Mdc!); + if (ping.Mdc is not { } pingMdc) + { + if (_logger.IsDebug) _logger.Debug($"Rejecting ping without packet hash from {node}"); + return; + } + + PongMsg msg = new(ping.FarAddress!, CalculateExpirationTime(), pingMdc); session.OnPingReceived(); await SendMessage(session, msg, token); @@ -286,15 +300,20 @@ public async Task OnIncomingMsg(DiscoveryMsg msg) if (_logger.IsTrace) _logger.Trace($"Received msg: {msg}"); MsgType msgType = msg.MsgType; Node node = new(msg.FarPublicKey, msg.FarAddress); - NodeSession session = GetSession(node); - session.RecordStatsForIncomingMsg(msg); - if (HandleViaMessageHandlers(node, msg)) + if (IsResponse(msgType)) { + if (!HandleViaMessageHandlers(node, msg)) return; + + NodeSession responseSession = GetSession(node); + responseSession.RecordStatsForIncomingMsg(msg); nodeHealthTracker.Value.OnIncomingMessageFrom(node); return; } + NodeSession session = GetSession(node); + session.RecordStatsForIncomingMsg(msg); + CancellationToken token = processExitSource.Token; switch (msgType) { @@ -305,18 +324,16 @@ public async Task OnIncomingMsg(DiscoveryMsg msg) nodeHealthTracker.Value.OnIncomingMessageFrom(node); break; case MsgType.FindNode: - await HandleFindNode(node, session, (FindNodeMsg)msg, token); - nodeHealthTracker.Value.OnIncomingMessageFrom(node); + if (await HandleFindNode(node, session, (FindNodeMsg)msg, token)) + { + nodeHealthTracker.Value.OnIncomingMessageFrom(node); + } break; case MsgType.EnrRequest: - await HandleEnrRequest(node, session, (EnrRequestMsg)msg, token); - nodeHealthTracker.Value.OnIncomingMessageFrom(node); - break; - - // Unsolicited response. - case MsgType.Neighbors: - case MsgType.Pong: - case MsgType.EnrResponse: + if (await HandleEnrRequest(node, session, (EnrRequestMsg)msg, token)) + { + nodeHealthTracker.Value.OnIncomingMessageFrom(node); + } break; default: if (_logger.IsError) _logger.Error($"Unsupported msgType: {msgType}"); @@ -333,6 +350,8 @@ public async Task OnIncomingMsg(DiscoveryMsg msg) } } + private static bool IsResponse(MsgType msgType) => msgType is MsgType.Neighbors or MsgType.Pong or MsgType.EnrResponse; + private bool ValidatePingAddress(PingMsg msg) { if (msg.DestinationAddress is null || msg.FarAddress is null) diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaModule.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaModule.cs new file mode 100644 index 000000000000..2d25f6c231d0 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/KademliaModule.cs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Autofac; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Db; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv4.Kademlia; + +/// +/// Specify the discv4 kademlia components. Mainly provide transport for . +/// Because kademlia can and probably will be reused outside of discv4, this module is meant to be added within a child +/// lifecycle in to prevent unexpected conflict. +/// +/// +/// +public sealed class KademliaModule(Node currentNode, IReadOnlyList bootNodes) : DiscoveryKademliaModuleBase(currentNode, bootNodes, DbNames.DiscoveryNodes) +{ + protected override void RegisterProtocolServices(ContainerBuilder builder) => builder + // This two class contains the actual `INodeSource` logic. As in finding nodes within the network. + .AddSingleton() + + // Some transport wiring. + .AddSingleton() + .Bind() + .Bind, IKademliaAdapter>() + .AddSingleton() + ; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/NodeSource.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/NodeSource.cs new file mode 100644 index 000000000000..27f8a385f2f5 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Kademlia/NodeSource.cs @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Nethermind.Core.Crypto; +using Nethermind.Logging; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv4.Kademlia; + +public sealed class NodeSource( + IKademlia kademlia, + IKademliaDiscovery kademliaDiscovery, + IKademliaAdapter discv4Adapter, + IDiscoveryConfig discoveryConfig, + KademliaConfig kademliaConfig, + ILogManager logManager) + : IKademliaNodeSource +{ + private const int ChannelCapacity = 64; + + private readonly ILogger _logger = logManager.GetClassLogger(); + private readonly Hash256 _currentNodeHash = kademliaConfig.CurrentNodeId.IdHash; + private readonly int _recentNodeLimit = RecentNodeFilter.GetLimit(kademliaConfig.KSize, Hash256KademliaDistance.Instance.MaxDistance, ChannelCapacity); + + public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] CancellationToken token) + { + if (_logger.IsDebug) _logger.Debug($"Starting discover nodes"); + using CancellationTokenSource disposeCts = CancellationTokenSource.CreateLinkedTokenSource(token); + CancellationToken discoveryToken = disposeCts.Token; + Channel ch = Channel.CreateBounded(ChannelCapacity); + RecentNodeFilter recentlyWrittenNodes = new(_recentNodeLimit); + + async Task DiscoverAsync() + { + try + { + await foreach (Node node in kademliaDiscovery.DiscoverNodes(discoveryConfig.ConcurrentDiscoveryJob, ChannelCapacity, discoveryToken)) + { + await WriteDiscoveredNode(node); + } + } + catch (OperationCanceledException) when (discoveryToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + if (_logger.IsError) _logger.Error("Kademlia discovery node stream failed.", ex); + } + } + + Task discoverTask = DiscoverAsync(); + + try + { + kademlia.OnNodeAdded += Handler; + + await foreach (Node node in ch.Reader.ReadAllAsync(token)) + { + yield return node; + } + } + finally + { + kademlia.OnNodeAdded -= Handler; + await disposeCts.CancelAsync(); + ch.Writer.TryComplete(); + try + { + await discoverTask; + } + catch (OperationCanceledException) when (discoveryToken.IsCancellationRequested) + { + } + } + + yield break; + + async Task WriteDiscoveredNode(Node node) + { + if (IsExcluded(node)) + { + return; + } + + if (!discv4Adapter.GetSession(node).HasReceivedPong) + { + if (discv4Adapter.GetSession(node).HasTriedPingRecently) + { + return; + } + + if (!await discv4Adapter.Ping(node, discoveryToken)) + { + return; + } + } + + if (!recentlyWrittenNodes.TryReserve(node.IdHash)) + { + return; + } + + try + { + await ch.Writer.WriteAsync(node, discoveryToken); + } + catch + { + recentlyWrittenNodes.Release(node.IdHash); + throw; + } + } + + void Handler(object? _, Node addedNode) + { + if (IsExcluded(addedNode)) + { + return; + } + + if (!recentlyWrittenNodes.TryReserve(addedNode.IdHash)) + { + return; + } + + if (ch.Writer.TryWrite(addedNode)) + { + return; + } + + recentlyWrittenNodes.Release(addedNode.IdHash); + } + } + + private bool IsExcluded(Node node) => node.IdHash.Equals(_currentNodeHash); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaNodeSource.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaNodeSource.cs deleted file mode 100644 index 00848bdb5132..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/KademliaNodeSource.cs +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Threading.Channels; -using Nethermind.Core.Crypto; -using Nethermind.Logging; -using Nethermind.Network.Discovery.Kademlia; -using Nethermind.Stats.Model; - -namespace Nethermind.Network.Discovery.Discv4; - -public class KademliaNodeSource( - IKademlia kademlia, - IIteratorNodeLookup lookup, - IKademliaDiscv4Adapter discv4Adapter, - IDiscoveryConfig discoveryConfig, - ILogManager logManager) - : IKademliaNodeSource -{ - private readonly ILogger _logger = logManager.GetClassLogger(); - - public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] CancellationToken token) - { - if (_logger.IsDebug) _logger.Debug($"Starting discover nodes"); - Channel ch = Channel.CreateBounded(64); - ConcurrentDictionary writtenNodes = new(); - int duplicated = 0; - int total = 0; - - async Task DiscoverAsync(PublicKey target) - { - if (_logger.IsDebug) _logger.Debug($"Looking up {target}"); - bool anyFound = false; - int count = 0; - - await foreach (Node node in lookup.Lookup(target, token)) - { - if (!discv4Adapter.GetSession(node).HasReceivedPong) - { - if (discv4Adapter.GetSession(node).HasTriedPingRecently) - { - // Tried ping before and did not receive a response - continue; - } - if (!await discv4Adapter.Ping(node, token)) - { - continue; - } - } - - anyFound = true; - count++; - total++; - if (!writtenNodes.TryAdd(node.IdHash, node.IdHash)) - { - duplicated++; - continue; - } - await ch.Writer.WriteAsync(node, token); - } - - if (!anyFound) - { - if (_logger.IsDebug) _logger.Debug($"No node found for {target}"); - } - else - { - if (_logger.IsDebug) _logger.Debug($"Found {count} nodes"); - } - } - - Task discoverTask = Task.WhenAll(Enumerable.Range(0, discoveryConfig.ConcurrentDiscoveryJob).Select((_) => Task.Run(async () => - { - Random random = new(); - byte[] randomBytes = new byte[64]; - while (!token.IsCancellationRequested) - { - Stopwatch iterationTime = Stopwatch.StartNew(); - - try - { - random.NextBytes(randomBytes); - await DiscoverAsync(new PublicKey(randomBytes)); - - // Prevent high CPU when all node is not reachable due to network connectivity issue. - if (iterationTime.Elapsed < TimeSpan.FromSeconds(1)) - { - await Task.Delay(TimeSpan.FromSeconds(1), token); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - if (_logger.IsError) _logger.Error($"Discovery via custom random walk failed.", ex); - } - } - }))); - - try - { - kademlia.OnNodeAdded += Handler; - - await foreach (Node node in ch.Reader.ReadAllAsync(token)) - { - yield return node; - } - } - finally - { - kademlia.OnNodeAdded -= Handler; - ch.Writer.TryComplete(); - await discoverTask; - } - - yield break; - - void Handler(object? _, Node addedNode) - { - writtenNodes.TryAdd(addedNode.IdHash, addedNode.IdHash); - ch.Writer.TryWrite(addedNode); // Ignore if channel full - } - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/DiscoveryMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/DiscoveryMsg.cs similarity index 95% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/DiscoveryMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/DiscoveryMsg.cs index e7f4855e4c6f..3cb8159130ac 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/DiscoveryMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/DiscoveryMsg.cs @@ -4,7 +4,7 @@ using System.Net; using Nethermind.Core.Crypto; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; public abstract class DiscoveryMsg : MessageBase { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/EnrRequestMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrRequestMsg.cs similarity index 74% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/EnrRequestMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrRequestMsg.cs index 6ce00eef3e32..30b0a5841fc2 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/EnrRequestMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrRequestMsg.cs @@ -4,16 +4,16 @@ using System.Net; using Nethermind.Core.Crypto; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; /// /// https://eips.ethereum.org/EIPS/eip-868 /// -public class EnrRequestMsg : DiscoveryMsg +public sealed class EnrRequestMsg : DiscoveryMsg { public override MsgType MsgType => MsgType.EnrRequest; - public Memory? Hash { get; set; } + public ValueHash256? Hash { get; set; } public EnrRequestMsg(IPEndPoint farAddress, long expirationDate) : base(farAddress, expirationDate) @@ -25,6 +25,6 @@ public EnrRequestMsg(PublicKey farPublicKey, long expirationDate) { } - internal EnrRequestMsg(PublicKey farPublicKey, Memory hash, long expirationDate) + internal EnrRequestMsg(PublicKey farPublicKey, ValueHash256 hash, long expirationDate) : base(farPublicKey, expirationDate) => Hash = hash; } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/EnrResponseMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrResponseMsg.cs similarity index 89% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/EnrResponseMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrResponseMsg.cs index e22c68b15b44..901eef6ffa74 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/EnrResponseMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/EnrResponseMsg.cs @@ -5,12 +5,12 @@ using Nethermind.Core.Crypto; using Nethermind.Network.Enr; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; /// /// https://eips.ethereum.org/EIPS/eip-868 /// -public class EnrResponseMsg : DiscoveryMsg +public sealed class EnrResponseMsg : DiscoveryMsg { private const long MaxTime = long.MaxValue; // non-expiring message diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/FindNodeMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/FindNodeMsg.cs similarity index 87% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/FindNodeMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/FindNodeMsg.cs index cc4ff14a10fc..e35ce4f8ebb4 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/FindNodeMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/FindNodeMsg.cs @@ -5,9 +5,9 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; -public class FindNodeMsg : DiscoveryMsg +public sealed class FindNodeMsg : DiscoveryMsg { public byte[] SearchedNodeId { get; set; } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/INodeIdResolver.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/INodeIdResolver.cs similarity index 82% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/INodeIdResolver.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/INodeIdResolver.cs index 802a887ca36d..2a165405e871 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/INodeIdResolver.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/INodeIdResolver.cs @@ -3,7 +3,7 @@ using Nethermind.Core.Crypto; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; public interface INodeIdResolver { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/NeighborsMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NeighborsMsg.cs similarity index 87% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/NeighborsMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NeighborsMsg.cs index 72d9a65b3f02..a1b59cdfcef7 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/NeighborsMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NeighborsMsg.cs @@ -5,9 +5,9 @@ using Nethermind.Core.Crypto; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; -public class NeighborsMsg : DiscoveryMsg +public sealed class NeighborsMsg : DiscoveryMsg { public ArraySegment Nodes { get; init; } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/NodeIdResolver.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NodeIdResolver.cs similarity index 76% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/NodeIdResolver.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NodeIdResolver.cs index 72d13fffbc82..64e82798f8c0 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/NodeIdResolver.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/NodeIdResolver.cs @@ -4,9 +4,9 @@ using Nethermind.Core.Crypto; using Nethermind.Crypto; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; -public class NodeIdResolver(IEcdsa ecdsa) : INodeIdResolver +public sealed class NodeIdResolver(IEcdsa ecdsa) : INodeIdResolver { private readonly IEcdsa _ecdsa = ecdsa; diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/PingMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PingMsg.cs similarity index 60% rename from src/Nethermind/Nethermind.Network.Discovery/Messages/PingMsg.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PingMsg.cs index 5326865f767b..6e4a4ca087a5 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/PingMsg.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PingMsg.cs @@ -3,11 +3,10 @@ using System.Net; using Nethermind.Core.Crypto; -using Nethermind.Core.Extensions; -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; -public class PingMsg : DiscoveryMsg +public sealed class PingMsg : DiscoveryMsg { public IPEndPoint SourceAddress { get; } public IPEndPoint DestinationAddress { get; } @@ -15,19 +14,24 @@ public class PingMsg : DiscoveryMsg /// /// Modification detection code /// - public byte[]? Mdc { get; set; } + public ValueHash256? Mdc { get; set; } /// /// https://eips.ethereum.org/EIPS/eip-868 /// - public long? EnrSequence { get; set; } + public ulong? EnrSequence { get; set; } public PingMsg(PublicKey farPublicKey, long expirationTime, IPEndPoint source, IPEndPoint destination, byte[] mdc) + : this(farPublicKey, expirationTime, source, destination, CreateHash(mdc)) + { + } + + public PingMsg(PublicKey farPublicKey, long expirationTime, IPEndPoint source, IPEndPoint destination, ValueHash256 mdc) : base(farPublicKey, expirationTime) { SourceAddress = source ?? throw new ArgumentNullException(nameof(source)); DestinationAddress = destination ?? throw new ArgumentNullException(nameof(destination)); - Mdc = mdc ?? throw new ArgumentNullException(nameof(mdc)); + Mdc = mdc; } public PingMsg(IPEndPoint farAddress, long expirationTime, IPEndPoint sourceAddress) @@ -37,7 +41,18 @@ public PingMsg(IPEndPoint farAddress, long expirationTime, IPEndPoint sourceAddr DestinationAddress = farAddress; } - public override string ToString() => base.ToString() + $", SourceAddress: {SourceAddress}, DestinationAddress: {DestinationAddress}, Version: {Version}, Mdc: {Mdc?.ToHexString()}"; + public override string ToString() => base.ToString() + $", SourceAddress: {SourceAddress}, DestinationAddress: {DestinationAddress}, Version: {Version}, Mdc: {(Mdc is { } mdc ? mdc.ToString() : null)}"; public override MsgType MsgType => MsgType.Ping; + + private static ValueHash256 CreateHash(byte[] mdc) + { + ArgumentNullException.ThrowIfNull(mdc); + if (mdc.Length != Hash256.Size) + { + throw new ArgumentException($"Discovery MDC must be {Hash256.Size} bytes.", nameof(mdc)); + } + + return new ValueHash256(mdc); + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PongMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PongMsg.cs new file mode 100644 index 000000000000..eac445fa0d21 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Messages/PongMsg.cs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using Nethermind.Core.Crypto; + +namespace Nethermind.Network.Discovery.Discv4.Messages; + +public sealed class PongMsg : DiscoveryMsg +{ + public ValueHash256 PingMdc { get; init; } + + public PongMsg(IPEndPoint farAddress, long expirationTime, ValueHash256 pingMdc) + : base(farAddress, expirationTime) => PingMdc = pingMdc; + + public PongMsg(PublicKey farPublicKey, long expirationTime, ValueHash256 pingMdc) + : base(farPublicKey, expirationTime) => PingMdc = pingMdc; + + public override string ToString() => base.ToString() + $", PingMdc: {PingMdc}"; + + public override MsgType MsgType => MsgType.Pong; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NettyDiscoveryHandler.cs similarity index 52% rename from src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryHandler.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/NettyDiscoveryHandler.cs index 9e92dc31c165..63bb58e5cceb 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NettyDiscoveryHandler.cs @@ -1,9 +1,13 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Sockets; +using System.Threading.Channels; using DotNetty.Buffers; +using DotNetty.Common.Utilities; using DotNetty.Transport.Channels; using DotNetty.Transport.Channels.Sockets; using FastEnumUtility; @@ -12,10 +16,10 @@ using Nethermind.Core.Collections; using Nethermind.Core.Extensions; using Nethermind.Logging; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using ILogger = Nethermind.Logging.ILogger; -namespace Nethermind.Network.Discovery; +namespace Nethermind.Network.Discovery.Discv4; public class NettyDiscoveryHandler( IDiscoveryMsgListener? discoveryManager, @@ -23,22 +27,37 @@ public class NettyDiscoveryHandler( IMessageSerializationService? msgSerializationService, ITimestamper? timestamper, ILogManager? logManager, - NodeFilter? inboundMessageFilter = null) : NettyDiscoveryBaseHandler(logManager), IMsgSender + NodeFilter? inboundMessageFilter = null, + int? globalInboundMessageBurst = null, + int? inboundMessageQueueCapacity = null, + int? inboundMessageWorkerCount = null) : NettyDiscoveryBaseHandler(logManager, channel ?? throw new ArgumentNullException(nameof(channel))), IMsgSender { private static readonly TimeSpan MaxFutureExpirationOffset = TimeSpan.FromHours(1); private static readonly TimeSpan DefaultInboundMessageWindow = TimeSpan.FromMilliseconds(100); - private const int DefaultInboundMessageBurstPerIp = 4; + private static readonly TimeSpan DefaultGlobalInboundMessageWindow = TimeSpan.FromMilliseconds(100); + private const int DefaultInboundMessageBurstPerIp = 8; private const int DefaultInboundMessageFilterSize = 8_192; + private const int DefaultGlobalInboundMessageBurst = 512; + private const int DefaultInboundMessageQueueCapacity = 1_024; + private const int DefaultInboundMessageWorkerCount = 4; private readonly ILogger _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); private readonly IDiscoveryMsgListener _discoveryMsgListener = discoveryManager ?? throw new ArgumentNullException(nameof(discoveryManager)); - private readonly IChannel _channel = channel ?? throw new ArgumentNullException(nameof(channel)); private readonly IMessageSerializationService _msgSerializationService = msgSerializationService ?? throw new ArgumentNullException(nameof(msgSerializationService)); private readonly ITimestamper _timestamper = timestamper ?? throw new ArgumentNullException(nameof(timestamper)); - private readonly NodeFilter[] _inboundMessageFilters = inboundMessageFilter is null - ? CreateDefaultInboundMessageFilters() - : [inboundMessageFilter]; + private readonly AddressBurstLimiter _inboundMessageLimiter = inboundMessageFilter is null + ? new(DefaultInboundMessageBurstPerIp, DefaultInboundMessageFilterSize, DefaultInboundMessageWindow) + : new(inboundMessageFilter); + private readonly FixedWindowLimiter _globalInboundMessageLimiter = new(Math.Max(1, globalInboundMessageBurst ?? DefaultGlobalInboundMessageBurst), DefaultGlobalInboundMessageWindow); + private readonly Channel _inboundMessages = System.Threading.Channels.Channel.CreateBounded( + new BoundedChannelOptions(Math.Max(1, inboundMessageQueueCapacity ?? DefaultInboundMessageQueueCapacity)) + { + SingleReader = false, + SingleWriter = false + }); + private readonly int _inboundMessageWorkerCount = Math.Max(1, inboundMessageWorkerCount ?? DefaultInboundMessageWorkerCount); + private int _dispatchWorkersStarted; - public override void ChannelActive(IChannelHandlerContext context) => OnChannelActivated?.Invoke(this, EventArgs.Empty); + protected override void CloseInbound() => _inboundMessages.Writer.TryComplete(); public override void ExceptionCaught(IChannelHandlerContext context, Exception exception) { @@ -63,7 +82,7 @@ public async Task SendMsg(DiscoveryMsg discoveryMsg) try { if (_logger.IsTrace) _logger.Trace($"Sending message: {discoveryMsg}"); - msgBuffer = Serialize(discoveryMsg, _channel.Allocator); + msgBuffer = Serialize(discoveryMsg, Channel.Allocator); } catch (Exception e) { @@ -89,7 +108,7 @@ public async Task SendMsg(DiscoveryMsg discoveryMsg) IAddressedEnvelope packet = new DatagramPacket(msgBuffer, discoveryMsg.FarAddress); try { - await _channel.WriteAndFlushAsync(packet); + await Channel.WriteAndFlushAsync(packet); } catch (Exception e) { @@ -100,19 +119,15 @@ public async Task SendMsg(DiscoveryMsg discoveryMsg) Metrics.DiscoveryMessagesSent.Increment(discoveryMsg.MsgType); } - private bool TryParseMessage(DatagramPacket packet, out DiscoveryMsg? msg, out bool shouldForward) + private bool TryAcceptPacket(DatagramPacket packet, out MsgType type, out bool shouldForward) { - msg = null; + type = default; shouldForward = true; IByteBuffer content = packet.Content; EndPoint address = packet.Sender; int size = content.ReadableBytes; - using ArrayPoolDisposableReturn handle = ArrayPoolDisposableReturn.Rent(size, out byte[] msgBytes); - - content.ReadBytes(msgBytes, 0, size); - Interlocked.Add(ref Metrics.DiscoveryBytesReceived, size); if (size < 98) @@ -121,28 +136,27 @@ private bool TryParseMessage(DatagramPacket packet, out DiscoveryMsg? msg, out b return false; } - if (FromMsgTypeByte(msgBytes[97]) is not { } type) + int readerIndex = content.ReaderIndex; + byte msgTypeByte = content.GetByte(readerIndex + 97); + if (FromMsgTypeByte(msgTypeByte) is not { } resolvedType) { - if (_logger.IsDebug) _logger.Debug($"Unsupported message type: {msgBytes[97]}, sender: {address}, message {msgBytes.AsSpan(0, size).ToHexString()}"); + if (_logger.IsDebug) _logger.Debug($"Unsupported message type: {msgTypeByte}, sender: {address}"); return false; } + + type = resolvedType; + shouldForward = false; if (_logger.IsTrace) _logger.Trace($"Received message: {type}"); - if (address is IPEndPoint remoteEndpoint && !TryAcceptInbound(remoteEndpoint)) + if (!_globalInboundMessageLimiter.TryAcquire()) { - if (_logger.IsDebug) _logger.Debug($"Rate limiting discovery message {type} from {remoteEndpoint}"); - shouldForward = false; + if (_logger.IsDebug) _logger.Debug($"Rate limiting discovery message globally, type: {type}, sender: {address}"); return false; } - try - { - msg = Deserialize(type, new ArraySegment(msgBytes, 0, size)); - msg.FarAddress = (IPEndPoint)address; - } - catch (Exception e) + if (address is IPEndPoint remoteEndpoint && !TryAcceptInbound(remoteEndpoint)) { - if (_logger.IsDebug) _logger.Debug($"Error during deserialization of the message, type: {type}, sender: {address}, msg: {msgBytes.AsSpan(0, size).ToHexString()}, {e.Message}"); + if (_logger.IsDebug) _logger.Debug($"Rate limiting discovery message {type} from {remoteEndpoint}"); return false; } @@ -151,7 +165,7 @@ private bool TryParseMessage(DatagramPacket packet, out DiscoveryMsg? msg, out b protected override void ChannelRead0(IChannelHandlerContext ctx, DatagramPacket packet) { - if (!TryParseMessage(packet, out DiscoveryMsg? msg, out bool shouldForward) || msg == null) + if (!TryAcceptPacket(packet, out MsgType type, out bool shouldForward)) { if (shouldForward) { @@ -161,33 +175,15 @@ protected override void ChannelRead0(IChannelHandlerContext ctx, DatagramPacket return; } - MsgType type = msg.MsgType; EndPoint address = packet.Sender; int size = packet.Content.ReadableBytes; + EnsureDispatchWorkersStarted(); - try - { - ReportMsgByType(msg, size); - - if (!ValidateMsg(msg, type, address, ctx, packet, size)) - return; - - // Explicitly run it on the default scheduler to prevent something down the line hanging netty task scheduler. - Task.Factory.StartNew( - static state => - { - (IDiscoveryMsgListener discoveryMsgListener, DiscoveryMsg discoveryMsg) = ((IDiscoveryMsgListener, DiscoveryMsg))state!; - discoveryMsgListener.OnIncomingMsg(discoveryMsg); - }, - (_discoveryMsgListener, msg), - CancellationToken.None, - TaskCreationOptions.RunContinuationsAsynchronously, - TaskScheduler.Default - ); - } - catch (Exception e) + packet.Retain(); + if (!_inboundMessages.Writer.TryWrite(new InboundDiscoveryPacket(ctx, packet, type, address, size))) { - _logger.DebugError($"Error while processing message, type: {type}, sender: {address}, message: {msg}", e); + ReferenceCountUtil.Release(packet); + if (_logger.IsDebug) _logger.Debug($"Dropping discovery message because inbound dispatch queue is full, type: {type}, sender: {address}"); } } @@ -216,21 +212,24 @@ protected override void ChannelRead0(IChannelHandlerContext ctx, DatagramPacket _ => throw new Exception($"Unsupported messageType: {msg.MsgType}") }; - private bool ValidateMsg(DiscoveryMsg msg, MsgType type, EndPoint address, IChannelHandlerContext ctx, DatagramPacket packet, int size) + private bool ValidateMsg(DiscoveryMsg msg, MsgType type, EndPoint address, DatagramPacket packet, int size) { - long timeToExpire = msg.ExpirationTime - _timestamper.UnixTime.SecondsLong; - if (timeToExpire < 0) + if (msg is not EnrResponseMsg) { - if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} expired", size); - if (_logger.IsDebug) _logger.Debug($"Received a discovery message that has expired {-timeToExpire} seconds ago, type: {type}, sender: {address}, message: {msg}"); - return false; - } + long timeToExpire = msg.ExpirationTime - _timestamper.UnixTime.SecondsLong; + if (timeToExpire < 0) + { + if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} expired", size); + if (_logger.IsDebug) _logger.Debug($"Received a discovery message that has expired {-timeToExpire} seconds ago, type: {type}, sender: {address}, message: {msg}"); + return false; + } - if (timeToExpire > MaxFutureExpirationOffset.TotalSeconds) - { - if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} far future", size); - if (_logger.IsDebug) _logger.Debug($"Received a discovery message that expires too far in the future ({timeToExpire} seconds), type: {type}, sender: {address}, message: {msg}"); - return false; + if (timeToExpire > MaxFutureExpirationOffset.TotalSeconds) + { + if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} far future", size); + if (_logger.IsDebug) _logger.Debug($"Received a discovery message that expires too far in the future ({timeToExpire} seconds), type: {type}, sender: {address}, message: {msg}"); + return false; + } } if (msg.FarAddress is null) @@ -243,14 +242,14 @@ private bool ValidateMsg(DiscoveryMsg msg, MsgType type, EndPoint address, IChan if (!msg.FarAddress.Equals((IPEndPoint)packet.Sender)) { if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} has incorrect far address", size); - if (_logger.IsDebug) _logger.Debug($"Discovery fake IP detected - pretended {msg.FarAddress} but was {ctx.Channel.RemoteAddress}, type: {type}, sender: {address}, message: {msg}"); + if (_logger.IsDebug) _logger.Debug($"Discovery fake IP detected - pretended {msg.FarAddress} but was {packet.Sender}, type: {type}, sender: {address}, message: {msg}"); return false; } if (msg.FarPublicKey is null) { if (NetworkDiagTracer.IsEnabled) NetworkDiagTracer.ReportIncomingMessage(msg.FarAddress, "disc v4", $"{msg.MsgType} has null far public key", size); - if (_logger.IsDebug) _logger.Debug($"Discovery message without a valid signature {msg.FarAddress} but was {ctx.Channel.RemoteAddress}, type: {type}, sender: {address}, message: {msg}"); + if (_logger.IsDebug) _logger.Debug($"Discovery message without a valid signature {msg.FarAddress} but was {packet.Sender}, type: {type}, sender: {address}, message: {msg}"); return false; } @@ -270,44 +269,157 @@ private static void ReportMsgByType(DiscoveryMsg msg, int size) Metrics.DiscoveryMessagesReceived.Increment(msg.MsgType); } + // Allow a small burst from the same IP so split Neighbors and other valid + // multi-packet exchanges are not dropped before signature verification. private bool TryAcceptInbound(IPEndPoint remoteEndpoint) + => _inboundMessageLimiter.TryAccept(remoteEndpoint.Address); + + private async Task LogDisconnectFailureAsync(Task disconnectTask) { - // Allow a small burst from the same IP so split Neighbors and other valid - // multi-packet exchanges are not dropped before signature verification. - NodeFilter[] inboundMessageFilters = _inboundMessageFilters; - for (int i = 0; i < inboundMessageFilters.Length; i++) + try { - if (inboundMessageFilters[i].TryAccept(remoteEndpoint.Address, exactOnly: true)) + await disconnectTask; + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Error while disconnecting on context on {this} : {e}"); + } + } + + private async Task ProcessInboundMessagesAsync() + { + try + { + await foreach (InboundDiscoveryPacket packet in _inboundMessages.Reader.ReadAllAsync()) { - return true; + try + { + ProcessInboundMessage(packet); + } + catch (Exception e) + { + if (_logger.IsError) _logger.Error($"Error while dispatching discovery message, type: {packet.Type}, sender: {packet.Address}", e); + } + finally + { + ReferenceCountUtil.Release(packet.Packet); + } } } + catch (Exception e) + { + if (_logger.IsError) _logger.Error("Error in discovery message dispatch loop", e); + } + } - return false; + private void ProcessInboundMessage(InboundDiscoveryPacket packet) + { + if (!TryDeserialize(packet, out DiscoveryMsg? msg)) + { + ForwardPacket(packet); + return; + } + + ReportMsgByType(msg, packet.Size); + + if (!ValidateMsg(msg, packet.Type, packet.Address, packet.Packet, packet.Size)) + { + ForwardPacket(packet); + return; + } + + // Discv4 request handling can wait for response packets that must be decoded by this same bounded queue. + DispatchMessage(msg); } - private static NodeFilter[] CreateDefaultInboundMessageFilters() + private void DispatchMessage(DiscoveryMsg msg) { - NodeFilter[] filters = new NodeFilter[DefaultInboundMessageBurstPerIp]; - for (int i = 0; i < filters.Length; i++) + Task dispatchTask = _discoveryMsgListener.OnIncomingMsg(msg); + if (!dispatchTask.IsCompletedSuccessfully) { - filters[i] = NodeFilter.CreateExact(DefaultInboundMessageFilterSize, DefaultInboundMessageWindow); + _ = ObserveDispatchFailure(dispatchTask, msg); } + } - return filters; + private async Task ObserveDispatchFailure(Task dispatchTask, DiscoveryMsg msg) + { + try + { + await dispatchTask; + } + catch (Exception e) + { + if (_logger.IsError) _logger.Error($"Error while handling discovery message, type: {msg.MsgType}, sender: {msg.FarAddress}", e); + } } - private async Task LogDisconnectFailureAsync(Task disconnectTask) + private bool TryDeserialize(InboundDiscoveryPacket packet, [NotNullWhen(true)] out DiscoveryMsg? msg) { + msg = null; + IByteBuffer content = packet.Packet.Content; + int readerIndex = content.ReaderIndex; + using ArrayPoolDisposableReturn handle = ArrayPoolDisposableReturn.Rent(packet.Size, out byte[] msgBytes); + content.GetBytes(readerIndex, msgBytes, 0, packet.Size); + try { - await disconnectTask; + msg = Deserialize(packet.Type, new ArraySegment(msgBytes, 0, packet.Size)); + msg.FarAddress = (IPEndPoint)packet.Address; + return true; } catch (Exception e) { - if (_logger.IsTrace) _logger.Trace($"Error while disconnecting on context on {this} : {e}"); + if (_logger.IsDebug) _logger.Debug($"Error during deserialization of the message, type: {packet.Type}, sender: {packet.Address}, msg: {msgBytes.AsSpan(0, packet.Size).ToHexString()}, {e.Message}"); + return false; + } + } + + private static void ForwardPacket(InboundDiscoveryPacket packet) + { + packet.Packet.Content.ResetReaderIndex(); + packet.Context.FireChannelRead(packet.Packet.Retain()); + } + + private void EnsureDispatchWorkersStarted() + { + if (Interlocked.Exchange(ref _dispatchWorkersStarted, 1) != 0) + { + return; + } + + for (int i = 0; i < _inboundMessageWorkerCount; i++) + { + _ = Task.Run(ProcessInboundMessagesAsync); + } + } + + private sealed class FixedWindowLimiter(int maxCount, TimeSpan window) + { + private readonly Lock _lock = new(); + private long _windowStartTicks = Stopwatch.GetTimestamp(); + private int _count; + + public bool TryAcquire() + { + lock (_lock) + { + long now = Stopwatch.GetTimestamp(); + if (Stopwatch.GetElapsedTime(_windowStartTicks, now) >= window) + { + _windowStartTicks = now; + _count = 0; + } + + if (_count >= maxCount) + { + return false; + } + + _count++; + return true; + } } } - public event EventHandler? OnChannelActivated; + private readonly record struct InboundDiscoveryPacket(IChannelHandlerContext Context, DatagramPacket Packet, MsgType Type, EndPoint Address, int Size); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSession.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSession.cs index 30a10933a274..d56802cc09d0 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSession.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSession.cs @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Net; using Nethermind.Core; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Stats; using Nethermind.Stats.Model; namespace Nethermind.Network.Discovery.Discv4; -public record NodeSession(INodeStats NodeStats, ITimestamper Timestamper) +public sealed record NodeSession(INodeStats NodeStats, ITimestamper Timestamper) { public static readonly TimeSpan BondTimeout = TimeSpan.FromHours(12); public static readonly TimeSpan PingRetryTimeout = TimeSpan.FromMinutes(10); @@ -18,15 +19,24 @@ public record NodeSession(INodeStats NodeStats, ITimestamper Timestamper) private long _lastPongReceivedTicks; private long _lastPingReceivedTicks; private long _lastPingSentTicks; + private IPEndPoint? _lastPongEndpoint; public bool HasReceivedPing => Volatile.Read(ref _lastPingReceivedTicks) + BondTimeout.Ticks > Timestamper.UtcNow.Ticks; public bool NotTooManyFailure => Volatile.Read(ref _authenticatedRequestFailureCount) <= AuthenticatedRequestFailureLimit; public bool HasReceivedPong => Volatile.Read(ref _lastPongReceivedTicks) + BondTimeout.Ticks > Timestamper.UtcNow.Ticks; public bool HasTriedPingRecently => Volatile.Read(ref _lastPingSentTicks) + PingRetryTimeout.Ticks > Timestamper.UtcNow.Ticks; + public bool HasEndpointProof(IPEndPoint endpoint) => + HasReceivedPong && Volatile.Read(ref _lastPongEndpoint) is { } lastPongEndpoint && lastPongEndpoint.Equals(endpoint); + public void ResetAuthenticatedRequestFailure() => Interlocked.Exchange(ref _authenticatedRequestFailureCount, 0); public void OnAuthenticatedRequestFailure() => Interlocked.Increment(ref _authenticatedRequestFailureCount); - public void OnPongReceived() => Volatile.Write(ref _lastPongReceivedTicks, Timestamper.UtcNow.Ticks); + public void OnPongReceived(IPEndPoint endpoint) + { + Volatile.Write(ref _lastPongEndpoint, endpoint); + Volatile.Write(ref _lastPongReceivedTicks, Timestamper.UtcNow.Ticks); + } + public void OnPingReceived() => Volatile.Write(ref _lastPingReceivedTicks, Timestamper.UtcNow.Ticks); public void RecordStatsForOutgoingMsg(DiscoveryMsg msg) => RecordStatsForMsg(msg, outgoing: true); diff --git a/src/Nethermind/Nethermind.Network.Discovery/NodeSourceToDiscV4Feeder.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSourceToDiscV4Feeder.cs similarity index 75% rename from src/Nethermind/Nethermind.Network.Discovery/NodeSourceToDiscV4Feeder.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSourceToDiscV4Feeder.cs index 52b021e1cdf1..c63b5a6810d9 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/NodeSourceToDiscV4Feeder.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/NodeSourceToDiscV4Feeder.cs @@ -5,9 +5,9 @@ using Nethermind.Config; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery; +namespace Nethermind.Network.Discovery.Discv4; -public class NodeSourceToDiscV4Feeder([KeyFilter(NodeSourceToDiscV4Feeder.SourceKey)] INodeSource nodeSource, IDiscoveryApp discoveryApp, IProcessExitSource exitSource, int maxNodes = 50) +public sealed class NodeSourceToDiscV4Feeder([KeyFilter(NodeSourceToDiscV4Feeder.SourceKey)] INodeSource nodeSource, IDiscoveryApp discoveryApp, IProcessExitSource exitSource, int maxNodes = 50) { public const string SourceKey = "Enr"; diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/DiscoveryMsgSerializerBase.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/DiscoveryMsgSerializerBase.cs similarity index 84% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/DiscoveryMsgSerializerBase.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/DiscoveryMsgSerializerBase.cs index de5c2e0cb577..0eef47311e27 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/DiscoveryMsgSerializerBase.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/DiscoveryMsgSerializerBase.cs @@ -8,10 +8,11 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; +using Nethermind.Network.Discovery.Serializers; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; public abstract class DiscoveryMsgSerializerBase(IEcdsa ecdsa, IPrivateKeyGenerator nodeKey, @@ -37,7 +38,7 @@ protected void Serialize(byte type, Span data, IByteBuffer byteBuffer) byteBuffer.SetWriterIndex(startWriteIndex + 32 + 65); byteBuffer.WriteByte(type); - byteBuffer.WriteBytes(data.ToArray(), 0, data.Length); + byteBuffer.WriteBytes(data); byteBuffer.SetReaderIndex(startReadIndex + 32 + 65); ValueHash256 toSign = ValueKeccak.Compute(byteBuffer.ReadAllBytesAsSpan()); @@ -54,7 +55,7 @@ protected void Serialize(byte type, Span data, IByteBuffer byteBuffer) byteBuffer.SetReaderIndex(startReadIndex); byteBuffer.SetWriterIndex(startWriteIndex); - byteBuffer.WriteBytes(mdc.BytesAsSpan.ToArray(), 0, 32); + byteBuffer.WriteBytes(mdc.BytesAsSpan); byteBuffer.SetWriterIndex(startWriteIndex + length); } @@ -82,13 +83,13 @@ protected void AddSignatureAndMdc(IByteBuffer byteBuffer, int dataLength) byteBuffer.SetReaderIndex(startReadIndex); byteBuffer.SetWriterIndex(startWriteIndex); - byteBuffer.WriteBytes(mdc.BytesAsSpan.ToArray(), 0, 32); + byteBuffer.WriteBytes(mdc.BytesAsSpan); byteBuffer.SetReaderIndex(startReadIndex); byteBuffer.SetWriterIndex(startWriteIndex + length); } - protected (PublicKey FarPublicKey, Memory Mdc, IByteBuffer Data) PrepareForDeserialization(IByteBuffer msg) + protected (PublicKey FarPublicKey, ValueHash256 Mdc, IByteBuffer Data) PrepareForDeserialization(IByteBuffer msg) { if (msg.ReadableBytes < 98) { @@ -96,11 +97,11 @@ protected void AddSignatureAndMdc(IByteBuffer byteBuffer, int dataLength) } IByteBuffer data = msg.Slice(98, msg.ReadableBytes - 98); Memory msgBytes = msg.ReadAllBytesAsMemory(); - Memory mdc = msgBytes[..32]; + ValueHash256 mdc = new(msgBytes.Span[..Hash256.Size]); Span sigAndData = msgBytes.Span[32..]; Span computedMdc = ValueKeccak.Compute(sigAndData).BytesAsSpan; - if (!Bytes.AreEqual(mdc.Span, computedMdc)) + if (!Bytes.AreEqual(mdc.Bytes, computedMdc)) { throw new NetworkingException("Invalid MDC", NetworkExceptionType.Validation); } @@ -109,10 +110,21 @@ protected void AddSignatureAndMdc(IByteBuffer byteBuffer, int dataLength) return (nodeId, mdc, data); } + protected static ValueHash256 ReadHash(IByteBuffer byteBuffer, int index) + { + Span hash = stackalloc byte[Hash256.Size]; + for (int i = 0; i < Hash256.Size; i++) + { + hash[i] = byteBuffer.GetByte(index + i); + } + + return new ValueHash256(hash); + } + protected static void Encode(RlpStream stream, IPEndPoint address, int length) { stream.StartSequence(length); - stream.Encode(address.Address.GetAddressBytes()); + IPAddressRlp.Encode(stream, address.Address); //tcp port stream.Encode(address.Port); //udp port @@ -121,7 +133,7 @@ protected static void Encode(RlpStream stream, IPEndPoint address, int length) protected static int GetIPEndPointLength(IPEndPoint address) { - int length = Rlp.LengthOf(address.Address.GetAddressBytes()); + int length = IPAddressRlp.GetLength(address.Address); length += Rlp.LengthOf(address.Port); length += Rlp.LengthOf(address.Port); return length; @@ -131,7 +143,7 @@ protected static void SerializeNode(RlpStream stream, IPEndPoint address, byte[] { int length = GetLengthSerializeNode(address, id); stream.StartSequence(length); - stream.Encode(address.Address.GetAddressBytes()); + IPAddressRlp.Encode(stream, address.Address); //tcp port stream.Encode(address.Port); //udp port @@ -141,7 +153,7 @@ protected static void SerializeNode(RlpStream stream, IPEndPoint address, byte[] protected static int GetLengthSerializeNode(IPEndPoint address, byte[] id) { - int length = Rlp.LengthOf(address.Address.GetAddressBytes()); + int length = IPAddressRlp.GetLength(address.Address); length += Rlp.LengthOf(address.Port); length += Rlp.LengthOf(address.Port); length += Rlp.LengthOf(id); @@ -156,9 +168,9 @@ protected static void PrepareBufferForSerialization(IByteBuffer byteBuffer, int byteBuffer.WriteByte(msgType); } - protected static IPEndPoint GetAddress(ReadOnlySpan ip, int port) + protected static IPEndPoint GetAddress(ReadOnlySpan ip, int port, bool allowZeroPort = false) { - if ((uint)(port - 1) >= ushort.MaxValue) + if (allowZeroPort ? (uint)port > ushort.MaxValue : (uint)(port - 1) >= ushort.MaxValue) { ThrowInvalidPort(port); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrRequestMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrRequestMsgSerializer.cs similarity index 73% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrRequestMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrRequestMsgSerializer.cs index cca1af0b8cb9..24db5d37b4f4 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrRequestMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrRequestMsgSerializer.cs @@ -5,12 +5,12 @@ using DotNetty.Buffers; using Nethermind.Core.Crypto; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; -public class EnrRequestMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer +public sealed class EnrRequestMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { public void Serialize(IByteBuffer byteBuffer, EnrRequestMsg msg) { @@ -27,13 +27,13 @@ public void Serialize(IByteBuffer byteBuffer, EnrRequestMsg msg) AddSignatureAndMdc(byteBuffer, length + 1); byteBuffer.MarkReaderIndex(); - msg.Hash = byteBuffer.Slice(0, 32).ReadAllBytesAsArray(); + msg.Hash = ReadHash(byteBuffer, byteBuffer.ReaderIndex); byteBuffer.ResetReaderIndex(); } public EnrRequestMsg Deserialize(IByteBuffer msgBytes) { - (PublicKey farPublicKey, Memory mdc, IByteBuffer data) = PrepareForDeserialization(msgBytes); + (PublicKey farPublicKey, ValueHash256 mdc, IByteBuffer data) = PrepareForDeserialization(msgBytes); Rlp.ValueDecoderContext ctx = data.AsRlpContext(); ctx.ReadSequenceLength(); diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrResponseMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrResponseMsgSerializer.cs similarity index 85% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrResponseMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrResponseMsgSerializer.cs index 94d8f91132d8..5f2f3bdd397b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/EnrResponseMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/EnrResponseMsgSerializer.cs @@ -6,13 +6,13 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.Enr; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; -public class EnrResponseMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer +public sealed class EnrResponseMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { private readonly NodeRecordSigner _nodeRecordSigner = new(ecdsa, nodeKey.Generate()); diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/FindNodeMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/FindNodeMsgSerializer.cs similarity index 80% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/FindNodeMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/FindNodeMsgSerializer.cs index 5eed89a7bcd9..342e74495219 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/FindNodeMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/FindNodeMsgSerializer.cs @@ -5,12 +5,12 @@ using DotNetty.Buffers; using Nethermind.Core.Crypto; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; -public class FindNodeMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer +public sealed class FindNodeMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { public void Serialize(IByteBuffer byteBuffer, FindNodeMsg msg) { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/NeighborsMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/NeighborsMsgSerializer.cs similarity index 79% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/NeighborsMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/NeighborsMsgSerializer.cs index c3d747506622..0067a452ef37 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/NeighborsMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/NeighborsMsgSerializer.cs @@ -6,13 +6,13 @@ using DotNetty.Buffers; using Nethermind.Core.Crypto; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; -public class NeighborsMsgSerializer( +public sealed class NeighborsMsgSerializer( IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, @@ -20,7 +20,8 @@ public class NeighborsMsgSerializer( : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { private static readonly RlpLimit NodesRlpLimit = RlpLimit.For(16, nameof(NeighborsMsg.Nodes)); - private static readonly DecodeRlpValue _decodeItem = static (ref ctx) => + + private static Node DecodeNode(ref Rlp.ValueDecoderContext ctx) { int lastPosition = ctx.ReadSequenceLength() + ctx.Position; int count = ctx.PeekNumberOfItemsRemaining(lastPosition); @@ -33,7 +34,7 @@ public class NeighborsMsgSerializer( ReadOnlySpan id = ctx.DecodeByteArraySpan(NodeIdRlpLimit); return new Node(new PublicKey(id), address); - }; + } public void Serialize(IByteBuffer byteBuffer, NeighborsMsg msg) { @@ -69,11 +70,31 @@ public NeighborsMsg Deserialize(IByteBuffer msgBytes) Rlp.ValueDecoderContext ctx = Data.AsRlpContext(); ctx.ReadSequenceLength(); - Node[] nodes = ctx.DecodeArray(_decodeItem, limit: NodesRlpLimit)!; + int nodesEnd = ctx.ReadSequenceLength() + ctx.Position; + int count = ctx.PeekNumberOfItemsRemaining(nodesEnd); + ctx.GuardLimit(count, NodesRlpLimit); + Node[] decoded = new Node[count]; + int nodeCount = 0; + for (int i = 0; i < count; i++) + { + if (ctx.IsNextItemEmptyList()) + { + ctx.SkipItem(); + continue; + } + + decoded[nodeCount++] = DecodeNode(ref ctx); + } + + ctx.Check(nodesEnd); + if (nodeCount != decoded.Length) + { + Array.Resize(ref decoded, nodeCount); + } long expirationTime = ctx.DecodeLong(); Data.SetReaderIndex(Data.ReaderIndex + ctx.Position); - NeighborsMsg msg = new(FarPublicKey, expirationTime, nodes); + NeighborsMsg msg = new(FarPublicKey, expirationTime, decoded); return msg; } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/PingMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PingMsgSerializer.cs similarity index 81% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/PingMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PingMsgSerializer.cs index 875fd4d98bb6..64461fac5188 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/PingMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PingMsgSerializer.cs @@ -6,10 +6,10 @@ using DotNetty.Buffers; using Nethermind.Core.Crypto; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; public class PingMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { @@ -37,13 +37,13 @@ public void Serialize(IByteBuffer byteBuffer, PingMsg msg) AddSignatureAndMdc(byteBuffer, totalLength + 1); byteBuffer.MarkReaderIndex(); - msg.Mdc = byteBuffer.Slice(0, 32).ReadAllBytesAsArray(); + msg.Mdc = ReadHash(byteBuffer, byteBuffer.ReaderIndex); byteBuffer.ResetReaderIndex(); } public PingMsg Deserialize(IByteBuffer msgBytes) { - (PublicKey FarPublicKey, Memory Mdc, IByteBuffer Data) = PrepareForDeserialization(msgBytes); + (PublicKey FarPublicKey, ValueHash256 Mdc, IByteBuffer Data) = PrepareForDeserialization(msgBytes); Rlp.ValueDecoderContext ctx = Data.AsRlpContext(); ctx.ReadSequenceLength(); int version = ctx.DecodeInt(); @@ -51,25 +51,24 @@ public PingMsg Deserialize(IByteBuffer msgBytes) ctx.ReadSequenceLength(); ReadOnlySpan sourceAddress = ctx.DecodeByteArraySpan(IpAddressRlpLimit); - // TODO: please note that we decode only one field for port and if the UDP is different from TCP then - // our discovery messages will not be routed correctly (the fix will not be part of this commit) - ctx.DecodeInt(); // UDP port - int tcpPort = ctx.DecodeInt(); // we assume here that UDP and TCP port are same + int sourceUdpPort = ctx.DecodeInt(); + ctx.DecodeInt(); // TCP port - IPEndPoint source = GetAddress(sourceAddress, tcpPort); + IPEndPoint source = GetAddress(sourceAddress, sourceUdpPort, allowZeroPort: true); ctx.ReadSequenceLength(); ReadOnlySpan destinationAddress = ctx.DecodeByteArraySpan(IpAddressRlpLimit); - IPEndPoint destination = GetAddress(destinationAddress, ctx.DecodeInt()); - ctx.DecodeInt(); // UDP port + int destinationUdpPort = ctx.DecodeInt(); + ctx.DecodeInt(); // TCP port + IPEndPoint destination = GetAddress(destinationAddress, destinationUdpPort, allowZeroPort: true); long expireTime = ctx.DecodeLong(); - PingMsg msg = new(FarPublicKey, expireTime, source, destination, Mdc.ToArray()) { Version = version }; + PingMsg msg = new(FarPublicKey, expireTime, source, destination, Mdc) { Version = version }; if (version == 4) { if (ctx.Position < ctx.Length) { - long enrSequence = ctx.DecodeLong(); + ulong enrSequence = ctx.DecodeULong(); msg.EnrSequence = enrSequence; } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/PongMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PongMsgSerializer.cs similarity index 73% rename from src/Nethermind/Nethermind.Network.Discovery/Serializers/PongMsgSerializer.cs rename to src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PongMsgSerializer.cs index 6a2f21e93349..b9d0dbabcc8d 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Serializers/PongMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv4/Serializers/PongMsgSerializer.cs @@ -5,12 +5,12 @@ using DotNetty.Buffers; using Nethermind.Core.Crypto; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; -namespace Nethermind.Network.Discovery.Serializers; +namespace Nethermind.Network.Discovery.Discv4.Serializers; -public class PongMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer +public sealed class PongMsgSerializer(IEcdsa ecdsa, [KeyFilter(IProtectedPrivateKey.NodeKey)] IPrivateKeyGenerator nodeKey, INodeIdResolver nodeIdResolver) : DiscoveryMsgSerializerBase(ecdsa, nodeKey, nodeIdResolver), IZeroInnerMessageSerializer { public void Serialize(IByteBuffer byteBuffer, PongMsg msg) { @@ -26,7 +26,8 @@ public void Serialize(IByteBuffer byteBuffer, PongMsg msg) NettyRlpStream stream = new(byteBuffer); stream.StartSequence(contentLength); Encode(stream, msg.FarAddress, farAddressLength); - stream.Encode(msg.PingMdc); + ValueHash256? pingMdc = msg.PingMdc; + stream.Encode(in pingMdc); stream.Encode(msg.ExpirationTime); byteBuffer.ResetIndex(); @@ -43,15 +44,19 @@ public PongMsg Deserialize(IByteBuffer msgBytes) ctx.ReadSequenceLength(); ctx.ReadSequenceLength(); - // GetAddress(ctx.DecodeByteArray(), ctx.DecodeInt()); ctx.DecodeByteArraySpan(IpAddressRlpLimit); ctx.DecodeInt(); // UDP port (we ignore and take it from Netty) ctx.DecodeInt(); // TCP port - byte[] token = ctx.DecodeByteArray(); + ReadOnlySpan token = ctx.DecodeByteArraySpan(RlpLimit.L32); + if (token.Length != Hash256.Size) + { + throw new NetworkingException($"PONG ping MDC must be {Hash256.Size} bytes.", NetworkExceptionType.Validation); + } + long expirationTime = ctx.DecodeLong(); data.SetReaderIndex(data.ReaderIndex + ctx.Position); - PongMsg msg = new(farPublicKey, expirationTime, token); + PongMsg msg = new(farPublicKey, expirationTime, new ValueHash256(token)); return msg; } @@ -71,7 +76,8 @@ private static (int totalLength, int contentLength, int farAddressLength) GetLen int farAddressLength = GetIPEndPointLength(message.FarAddress); int contentLength = Rlp.LengthOfSequence(farAddressLength); - contentLength += Rlp.LengthOf(message.PingMdc); + ValueHash256? pingMdc = message.PingMdc; + contentLength += Rlp.LengthOf(in pingMdc); contentLength += Rlp.LengthOf(message.ExpirationTime); return (Rlp.LengthOfSequence(contentLength), contentLength, farAddressLength); diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5App.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5App.cs index b0eb2bee2853..43c4591a0fb7 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5App.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5App.cs @@ -1,197 +1,206 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text.Json; +using Autofac; using Autofac.Features.AttributeFilters; +using Collections.Pooled; using DotNetty.Transport.Channels; -using Lantern.Discv5.Enr; -using Lantern.Discv5.Enr.Entries; -using Lantern.Discv5.Enr.Identity.V4; -using Lantern.Discv5.WireProtocol; -using Lantern.Discv5.WireProtocol.Connection; -using Lantern.Discv5.WireProtocol.Session; -using Lantern.Discv5.WireProtocol.Table; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using NBitcoin.Secp256k1; +using Nethermind.Config; using Nethermind.Core; -using Nethermind.Core.Collections; using Nethermind.Core.Crypto; -using Nethermind.Core.ServiceStopper; using Nethermind.Crypto; -using Nethermind.Db; +using Nethermind.Kademlia; using Nethermind.Logging; using Nethermind.Network.Config; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Network.Discovery.Discv5.Kademlia; +using Nethermind.Network.Enr; using Nethermind.Stats.Model; -using System.Diagnostics.CodeAnalysis; -using System.Net; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading.Channels; -using ENR = Lantern.Discv5.Enr.Enr; +using Discv5KademliaModule = Nethermind.Network.Discovery.Discv5.Kademlia.KademliaModule; [assembly: InternalsVisibleTo("Nethermind.Network.Discovery.Test")] namespace Nethermind.Network.Discovery.Discv5; -public sealed class DiscoveryV5App : IDiscoveryApp +public sealed class DiscoveryV5App : KademliaDiscoveryApp { - internal const int MaxPendingEnrsPerWalk = 4_096; - internal const int MaxTrackedEnrsPerWalk = MaxPendingEnrsPerWalk * 2; - private readonly IDiscv5Protocol _discv5Protocol; - private readonly Logging.ILogger _logger; - private readonly IDb _discoveryDb; - private readonly IDb _legacyDiscoveryDb; - private readonly ILogManager _logManager; - private readonly CancellationTokenSource _appShutdownSource = new(); - private DiscoveryV5Report? _discoveryReport; - private readonly IServiceProvider _serviceProvider; - private readonly SessionOptions _sessionOptions; - private readonly EnrFactory _enrFactory; private readonly bool _allowNonRoutableEnrs; - private readonly RateLimiter _outgoingMessageRateLimiter; + private readonly DiscoveryPersistenceManager _persistenceManager; + private readonly IKademliaAdapter _discv5Adapter; + private readonly Func _discoveryHandlerFactory; + private readonly ILifetimeScope _discv5Services; + + private NettyDiscoveryV5Handler? _discoveryHandler; public DiscoveryV5App( + ILifetimeScope rootScope, [KeyFilter(IProtectedPrivateKey.NodeKey)] IProtectedPrivateKey nodeKey, + IEnode enode, IIPResolver ipResolver, INetworkConfig networkConfig, IDiscoveryConfig discoveryConfig, - [KeyFilter(DbNames.DiscoveryV5Nodes)] IDb discoveryDb, - [KeyFilter(DbNames.DiscoveryNodes)] IDb legacyDiscoveryDb, - ILogManager logManager) + IProcessExitSource processExitSource, + ILogManager logManager, + Action? configureDiscv5Services = null) + : base("discv5", networkConfig, ipResolver, processExitSource, logManager.GetClassLogger()) { - _logger = logManager.GetClassLogger(); - _discoveryDb = discoveryDb; - _legacyDiscoveryDb = legacyDiscoveryDb; - _logManager = logManager; - // DiscoveryV5App is resolved during network startup, after SetupKeyStore (a declared dependency of - // InitializeNetwork) has already awaited Resolve() and warmed the cache, so this does not block. - IPAddress externalIp = ipResolver.Resolve().GetAwaiter().GetResult().ExternalIp; + IPAddress externalIp = enode.HostIp; _allowNonRoutableEnrs = ShouldAcceptNonRoutableEnrs(externalIp); - IdentityVerifierV4 identityVerifier = new(); - PrivateKey privateKey = nodeKey.Unprotect(); - _sessionOptions = new() - { - Signer = new IdentitySignerV4(privateKey.KeyBytes), - Verifier = identityVerifier, - SessionKeys = new SessionKeys(privateKey.KeyBytes), - }; - - IServiceCollection services = new ServiceCollection() - .AddSingleton() - .AddSingleton(_sessionOptions.Verifier) - .AddSingleton(_sessionOptions.Signer); - - _enrFactory = new EnrFactory(new EnrEntryRegistry()); - - ENR[] bootstrapEnrs = [ - .. networkConfig.Bootnodes.Select(bn => bn.ToEnr(_sessionOptions.Verifier, _sessionOptions.Signer)), - .. discoveryConfig.UseDefaultDiscv5Bootnodes ? GetDefaultDiscv5Bootnodes().Select(ToEnr) : [], - .. LoadStoredEnrs(), - ]; - - EnrBuilder enrBuilder = new EnrBuilder() - .WithIdentityScheme(_sessionOptions.Verifier, _sessionOptions.Signer) - .WithEntry(EnrEntryKey.Id, new EntryId("v4")) - .WithEntry(EnrEntryKey.Ip, new EntryIp(externalIp)) - .WithEntry(EnrEntryKey.Secp256K1, new EntrySecp256K1(_sessionOptions.Signer.PublicKey)) - .WithEntry(EnrEntryKey.Tcp, new EntryTcp(networkConfig.P2PPort)) - .WithEntry(EnrEntryKey.Udp, new EntryUdp(networkConfig.DiscoveryPort)); - - IDiscv5ProtocolBuilder discv5Builder = new Discv5ProtocolBuilder(services) - .WithConnectionOptions(new ConnectionOptions - { - UdpPort = networkConfig.DiscoveryPort - }) - .WithSessionOptions(_sessionOptions) - .WithTableOptions(new TableOptions([.. bootstrapEnrs.Select(enr => enr.ToString())])) - .WithEnrBuilder(enrBuilder) - .WithTalkResponder(new TalkReqAndRespHandler()) - .WithLoggerFactory(new NethermindLoggerFactory(logManager, true, Microsoft.Extensions.Logging.LogLevel.Debug)) - .WithServices(s => - { - s.AddSingleton(logManager); - NettyDiscoveryV5Handler.Register(s); - }); + List bootNodes = CreateBootNodes(networkConfig, discoveryConfig); + ITimestamper timestamper = rootScope.ResolveOptional() ?? Timestamper.Default; - _discv5Protocol = NetworkHelper.HandlePortTakenError(discv5Builder.Build, networkConfig.DiscoveryPort); + _discv5Services = rootScope.BeginLifetimeScope(builder => + { + builder.RegisterInstance(discoveryConfig).As(); + builder.RegisterInstance(timestamper).As(); + Node currentNode = new(nodeKey.PublicKey, externalIp.ToString(), networkConfig.DiscoveryPort, true); + builder + .AddModule(new Discv5KademliaModule(currentNode, bootNodes)) + .AddSingleton(); + + configureDiscv5Services?.Invoke(builder); + }); + + DiscV5Services services = _discv5Services.Resolve(); + _persistenceManager = services.PersistenceManager; + _discv5Adapter = services.Discv5Adapter; + _discoveryHandlerFactory = services.NettyDiscoveryHandlerFactory; + UseKademliaServices(services.NodeSource, services.Kademlia); + } - _serviceProvider = discv5Builder.GetServiceProvider(); - _outgoingMessageRateLimiter = new RateLimiter(discoveryConfig.MaxOutgoingMessagePerSecond); + /// + /// Just a small class to make resolve easier + /// + private record DiscV5Services( + IKademliaNodeSource NodeSource, + DiscoveryPersistenceManager PersistenceManager, + IKademliaAdapter Discv5Adapter, + IKademlia Kademlia, + Func NettyDiscoveryHandlerFactory + ) + { } - private static string[] GetDefaultDiscv5Bootnodes() => - JsonSerializer.Deserialize(typeof(DiscoveryV5App).Assembly.GetManifestResourceStream("Nethermind.Network.Discovery.Discv5.discv5-bootnodes.json")!) ?? []; - private ENR ToEnr(string enrString) => _enrFactory.CreateFromString(enrString, _sessionOptions.Verifier!); + internal List CreateBootNodes(INetworkConfig networkConfig, IDiscoveryConfig discoveryConfig) + { + List bootNodes = []; + using PooledSet seen = new(networkConfig.Bootnodes.Length); + BootNodeStats configuredStats = new(); + BootNodeStats defaultStats = new(); - private ENR ToEnr(byte[] enrBytes) => _enrFactory.CreateFromBytes(enrBytes, _sessionOptions.Verifier!); + NetworkNode[] configuredBootnodes = networkConfig.Bootnodes; + for (int i = 0; i < configuredBootnodes.Length; i++) + { + configuredStats.Record(AddBootNode(bootNodes, seen, configuredBootnodes[i])); + } - private ENR ToEnr(Node node) => new EnrBuilder() - .WithIdentityScheme(_sessionOptions.Verifier!, _sessionOptions.Signer!) - .WithEntry(EnrEntryKey.Id, new EntryId("v4")) - .WithEntry(EnrEntryKey.Ip, new EntryIp(node.Address.Address)) - .WithEntry(EnrEntryKey.Secp256K1, new EntrySecp256K1(node.Id.PrefixedBytes)) - .WithEntry(EnrEntryKey.Tcp, new EntryTcp(node.Address.Port)) - .WithEntry(EnrEntryKey.Udp, new EntryUdp(node.Address.Port)) - .Build(); + if (discoveryConfig.UseDefaultDiscv5Bootnodes) + { + string[] defaultBootnodes = GetDefaultBootnodes(); + for (int i = 0; i < defaultBootnodes.Length; i++) + { + defaultStats.Record(AddBootNode(bootNodes, seen, NodeRecord.FromEnrString(defaultBootnodes[i]))); + } + } - internal bool TryGetNodeFromEnr(IEnr enr, [NotNullWhen(true)] out Node? node) - { - static PublicKey? GetPublicKeyFromEnr(IEnr entry) + if (Logger.IsInfo) { - byte[] keyBytes = entry.GetEntry(EnrEntryKey.Secp256K1).Value; - return Context.Instance.TryCreatePubKey(keyBytes, out _, out ECPubKey? key) ? new PublicKey(key.ToBytes(false)) : null; + Logger.Info($"Discv5 bootnodes accepted: {bootNodes.Count} ({configuredStats.Added}/{configuredStats.Total} configured, {defaultStats.Added}/{defaultStats.Total} default, duplicates: {configuredStats.Duplicates + defaultStats.Duplicates}, skipped: {configuredStats.Skipped + defaultStats.Skipped}, use default discv5 bootnodes: {discoveryConfig.UseDefaultDiscv5Bootnodes})."); } - node = null; - if (!enr.HasKey(EnrEntryKey.Tcp)) + if (bootNodes.Count == 0 && Logger.IsWarn) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, no TCP port."); - return false; + Logger.Warn("No discv5 bootnodes specified in configuration"); } - if (!enr.HasKey(EnrEntryKey.Ip)) + + return bootNodes; + } + + public override void AddNodeToDiscovery(Node node) + { + if (string.IsNullOrEmpty(node.Enr)) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, no IP."); - return false; + return; } - if (!enr.HasKey(EnrEntryKey.Secp256K1)) + + try { - if (_logger.IsTrace) _logger.Trace($"Enr declined, no signature."); - return false; + NodeRecord record = NodeRecord.FromEnrString(node.Enr); + if (!TryGetAcceptableNodeFromEnr(record, out Node? enrNode)) + { + return; + } + + if (!enrNode.IdHash.Equals(node.IdHash)) + { + if (Logger.IsTrace) Logger.Trace($"Skipping discv5 discovery node {node:s} with mismatched ENR identity."); + return; + } + + Kademlia.AddOrRefresh(enrNode); } - if (enr.HasKey(EnrEntryKey.Eth2)) + catch (Exception e) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, ETH2 detected."); - return false; + if (Logger.IsTrace) Logger.Trace($"Unable to parse discv5 discovery ENR for {node}: {e}"); } + } - PublicKey? key = GetPublicKeyFromEnr(enr); - if (key is null) + private BootNodeAddResult AddBootNode(List bootNodes, ISet seen, NetworkNode networkNode) + { + if (networkNode.IsEnr) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, unable to extract public key."); - return false; + return AddBootNode(bootNodes, seen, networkNode.Enr); } - IPAddress ip = enr.GetEntry(EnrEntryKey.Ip).Value; - int tcpPort = enr.GetEntry(EnrEntryKey.Tcp).Value; - if (!IsDiscoveryAddressAcceptable(ip, _allowNonRoutableEnrs)) + Node node = new(networkNode.NodeId, networkNode.Host, networkNode.DiscoveryPort); + return AddBootNode(bootNodes, seen, node); + } + + private BootNodeAddResult AddBootNode(List bootNodes, ISet seen, NodeRecord nodeRecord) + => TryGetAcceptableNodeFromEnr(nodeRecord, out Node? node) + ? AddBootNode(bootNodes, seen, node) + : BootNodeAddResult.Skipped; + + private BootNodeAddResult AddBootNode(List bootNodes, ISet seen, Node node) + { + if (!seen.Add(node.IdHash)) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, non-routable IP {ip}."); - return false; + if (Logger.IsTrace) Logger.Trace($"Skipping duplicate discv5 bootnode {node:s}."); + return BootNodeAddResult.Duplicate; } - if ((uint)tcpPort > ushort.MaxValue || tcpPort == 0) + node.IsBootnode = true; + bootNodes.Add(node); + if (Logger.IsDebug) Logger.Debug($"Accepted discv5 bootnode {node:s}, has ENR: {!string.IsNullOrEmpty(node.Enr)}."); + return BootNodeAddResult.Added; + } + + private static string[] GetDefaultBootnodes() => + JsonSerializer.Deserialize(typeof(DiscoveryV5App).Assembly.GetManifestResourceStream("Nethermind.Network.Discovery.Discv5.discv5-bootnodes.json")!) ?? []; + + internal bool TryGetAcceptableNodeFromEnr(NodeRecord enr, [NotNullWhen(true)] out Node? node) + { + if (IsConsensusOnlyNodeRecord(enr)) { - if (_logger.IsTrace) _logger.Trace($"Enr declined, invalid TCP port {tcpPort}."); + node = null; + if (Logger.IsTrace) Logger.Trace("Enr declined, consensus-only ENRs are not execution discovery peers."); return false; } - node = new(key, ip.ToString(), tcpPort) + if (Node.TryFromDiscoveryEnr(enr, out Node? enrNode) && IsDiscoveryAddressAcceptable(enrNode.Address.Address, _allowNonRoutableEnrs)) { - Enr = enr.ToString() - }; - return true; + node = enrNode; + return true; + } + + node = null; + if (Logger.IsTrace) Logger.Trace("Enr declined, unable to extract a usable discv5 node endpoint."); + return false; } internal static bool IsDiscoveryAddressAcceptable(IPAddress ipAddress, bool allowNonRoutable) @@ -201,265 +210,118 @@ internal static bool IsDiscoveryAddressAcceptable(IPAddress ipAddress, bool allo return false; } - if (ipAddress.IsIPv6Multicast || IsIPv4Multicast(ipAddress)) + if (ipAddress.IsMulticast) { return false; } - return allowNonRoutable || !NodeFilter.IsLoopbackOrPrivateOrLinkLocal(ipAddress); + if (ipAddress.IsSpecialUseAddress) + { + return false; + } + + return allowNonRoutable || !ipAddress.IsLoopbackOrPrivateOrLinkLocal; } internal static bool IsDiscoveryAddressRoutable(IPAddress ipAddress) => IsDiscoveryAddressAcceptable(ipAddress, allowNonRoutable: false); - private static bool IsIPv4Multicast(IPAddress ipAddress) - => NodeFilter.IsIPv4Multicast(ipAddress); + internal static bool IsConsensusOnlyNodeRecord(NodeRecord enr) + => enr.HasEntry(EnrContentKey.Eth2) && !enr.HasEntry(EnrContentKey.Eth); private static bool ShouldAcceptNonRoutableEnrs(IPAddress externalIp) => !IPAddress.Any.Equals(externalIp) && !IPAddress.None.Equals(externalIp) - && NodeFilter.IsLoopbackOrPrivateOrLinkLocal(externalIp); + && externalIp.IsLoopbackOrPrivateOrLinkLocal; - internal static bool TryEnqueueNewEnr(Queue nodesToCheck, HashSet seenNodes, IEnr enr) + public override void InitializeChannel(IChannel channel) { - if (seenNodes.Count >= MaxTrackedEnrsPerWalk || nodesToCheck.Count >= MaxPendingEnrsPerWalk || !seenNodes.Add(enr)) - { - return false; - } - - nodesToCheck.Enqueue(enr); - return true; + _discoveryHandler = _discoveryHandlerFactory(); + _discoveryHandler.InitializeChannel(channel); + _discoveryHandler.OnChannelActivated += OnChannelActivated; + channel.Pipeline.AddLast(_discoveryHandler); } - internal List LoadStoredEnrs() + protected override void DetachEventHandlers() { - List enrs = [.. _discoveryDb.GetAllValues().Select(ToEnr)]; - - if (enrs.Count is not 0) - { - return enrs; - } - - IWriteBatch? migrateBatch = null; - IWriteBatch? deleteBatch = null; - try { - foreach (KeyValuePair kv in _legacyDiscoveryDb.GetAll()) + if (_discoveryHandler is not null) { - if (kv.Value is null) - { - continue; - } - - try - { - ENR enr = ToEnr(kv.Value); - - if (enrs.Count is 0) - { - migrateBatch = _discoveryDb.StartWriteBatch(); - deleteBatch = _legacyDiscoveryDb.StartWriteBatch(); - } - - enrs.Add(enr); - migrateBatch![enr.NodeId] = kv.Value; - deleteBatch![kv.Key] = null; - } - catch - { - // The database has enodes only - return []; - } + _discoveryHandler.OnChannelActivated -= OnChannelActivated; } } - finally + catch (Exception e) { - migrateBatch?.Dispose(); - deleteBatch?.Dispose(); + Logger.Error("Error during discovery v5 cleanup", e); } - - return enrs; - } - - public event EventHandler? NodeRemoved { add { } remove { } } - - public void InitializeChannel(IChannel channel) - { - NettyDiscoveryV5Handler handler = _serviceProvider.GetRequiredService(); - handler.InitializeChannel(channel); - channel.Pipeline.AddLast(handler); } - public async Task StartAsync() + protected override async Task RunDiscoveryAsync(CancellationToken cancellationToken) { - await _discv5Protocol.InitAsync(); + Task adapterTask = _discv5Adapter.RunAsync(cancellationToken); + Task? persistenceTask = null; - if (_logger.IsDebug) _logger.Debug($"Initially discovered {_discv5Protocol.GetActiveNodes.Count()} active peers, {_discv5Protocol.GetAllNodes.Count()} in total."); - - _discoveryReport = new DiscoveryV5Report(_discv5Protocol, _logManager, _appShutdownSource.Token); - } - - public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] CancellationToken token) - { - Channel discoveredNodesChannel = Channel.CreateBounded(1); + try + { + await _persistenceManager.LoadPersistedNodes(cancellationToken); - async Task DiscoverAsync(IEnumerable startingNode, ArrayPoolSpan nodeId, bool disposeNodeId = true) + persistenceTask = _persistenceManager.RunDiscoveryPersistenceCommit(cancellationToken); + await Kademlia.Run(cancellationToken); + } + finally { try { - static int[] GetDistances(byte[] srcNodeId, in ArrayPoolSpan destNodeId) + if (persistenceTask is not null) { - const int WiderDistanceRange = 3; - - int[] distances = new int[WiderDistanceRange]; - distances[0] = TableUtility.Log2Distance(srcNodeId, destNodeId); - - for (int n = 1, i = 1; n < WiderDistanceRange; i++) - { - if (distances[0] - i > 0) - { - distances[n++] = distances[0] - i; - } - if (distances[0] + i <= 256) - { - distances[n++] = distances[0] + i; - } - } - - return distances; - } - - Queue nodesToCheck = new(startingNode); - HashSet seenNodes = [.. startingNode]; - HashSet checkedNodes = []; - - while (!token.IsCancellationRequested) - { - if (!nodesToCheck.TryDequeue(out IEnr? newEntry)) - { - return; - } - - if (TryGetNodeFromEnr(newEntry, out Node? node2)) - { - await discoveredNodesChannel.Writer.WriteAsync(node2!, token); - - if (_logger.IsDebug) _logger.Debug($"A node discovered via discv5: {newEntry} = {node2}."); - - _discoveryReport?.NodeFound(); - } - - if (!checkedNodes.Add(newEntry)) - { - continue; - } - - await _outgoingMessageRateLimiter.WaitAsync(token); - foreach (IEnr newEnr in await _discv5Protocol.SendFindNodeAsync(newEntry, GetDistances(newEntry.NodeId, in nodeId)) ?? []) - { - TryEnqueueNewEnr(nodesToCheck, seenNodes, newEnr); - } + await persistenceTask; } } finally { - if (disposeNodeId) - { - nodeId.Dispose(); - } + await adapterTask; } } + } - IEnumerable GetStartingNodes() => _discv5Protocol.GetAllNodes; - Random random = new(); - - const int RandomNodesToLookupCount = 3; - - Task discoverTask = Task.Run(async () => - { - using ArrayPoolSpan selfNodeId = new(32); - _discv5Protocol.SelfEnr.NodeId.CopyTo(selfNodeId); - - while (!token.IsCancellationRequested) - { - try - { - using ArrayPoolList discoverTasks = new(RandomNodesToLookupCount); - - discoverTasks.Add(DiscoverAsync(GetStartingNodes(), selfNodeId, false)); - - for (int i = 0; i < RandomNodesToLookupCount; i++) - { - ArrayPoolSpan randomNodeId = new(32); - random.NextBytes(randomNodeId); - discoverTasks.Add(DiscoverAsync(GetStartingNodes(), randomNodeId)); - } + protected override async Task StopAsyncCore() + { + await _discv5Adapter.DisposeAsync(); + _discoveryHandler?.Close(); + } - await Task.WhenAll(discoverTasks); - await Task.Delay(TimeSpan.FromSeconds(2), token); - } - catch (OperationCanceledException) - { - if (_logger.IsTrace) _logger.Trace($"Discovery has been stopped."); - } - catch (Exception ex) - { - if (_logger.IsError) _logger.Error($"Discovery via custom random walk failed.", ex); - } - } - }, token); + protected override ValueTask DisposeAsyncCore() => _discv5Services.DisposeAsync(); - try - { - await foreach (Node node in discoveredNodesChannel.Reader.ReadAllAsync(token)) - { - yield return node; - } - } - finally - { - await discoverTask; - } + private enum BootNodeAddResult + { + Added, + Duplicate, + Skipped } - public async Task StopAsync() + private struct BootNodeStats { - IEnumerable activeNodeEnrs = _discv5Protocol.GetAllNodes; - _discoveryDb.Clear(); - - IWriteBatch? batch = null; + public int Total { get; private set; } + public int Added { get; private set; } + public int Duplicates { get; private set; } + public int Skipped { get; private set; } - try + public void Record(BootNodeAddResult result) { - foreach (IEnr enr in activeNodeEnrs) + Total++; + switch (result) { - batch ??= _discoveryDb.StartWriteBatch(); - batch[enr.NodeId] = enr.EncodeRecord(); + case BootNodeAddResult.Added: + Added++; + break; + case BootNodeAddResult.Duplicate: + Duplicates++; + break; + case BootNodeAddResult.Skipped: + Skipped++; + break; } } - finally - { - batch?.Dispose(); - } - - try - { - await _discv5Protocol.StopAsync(); - } - catch (Exception ex) - { - if (_logger.IsWarn) _logger.Warn($"Error when attempting to stop discv5: {ex}"); - } - - await _appShutdownSource.CancelAsync(); - } - - string IStoppableService.Description => "discv5"; - - public void AddNodeToDiscovery(Node node) - { - IRoutingTable routingTable = _serviceProvider.GetRequiredService(); - routingTable.UpdateFromEnr(ToEnr(node)); } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5Report.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5Report.cs deleted file mode 100644 index ed9da22a9d58..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/DiscoveryV5Report.cs +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Lantern.Discv5.WireProtocol; -using Nethermind.Logging; - -namespace Nethermind.Network.Discovery.Discv5; - -internal class DiscoveryV5Report -{ - int RecentlyChecked = 0; - int TotalChecked = 0; - - public DiscoveryV5Report(IDiscv5Protocol discv5Protocol, ILogManager logManager, CancellationToken token) - { - ILogger logger = logManager.GetClassLogger(); - if (!logger.IsDebug) - { - return; - } - - _ = Task.Run(async () => - { - while (!token.IsCancellationRequested) - { - logger.Debug($"Nodes checked: {Interlocked.Exchange(ref RecentlyChecked, 0)}, in total {TotalChecked}. Kademlia table state: {discv5Protocol.GetActiveNodes.Count()} active nodes, {discv5Protocol.GetAllNodes.Count()} all nodes."); - await Task.Delay(10_000, token); - } - }, token); - } - - public void NodeFound() - { - Interlocked.Increment(ref RecentlyChecked); - Interlocked.Increment(ref TotalChecked); - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/AdapterState.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/AdapterState.cs new file mode 100644 index 000000000000..324651eb9701 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/AdapterState.cs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Buffers.Binary; +using System.Net; +using Nethermind.Core.Crypto; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +internal readonly record struct SessionKey(ValueHash256 NodeId, IPEndPoint Endpoint); + +internal readonly record struct ChallengeKey(ValueHash256 NodeId, IPEndPoint Endpoint); + +internal readonly record struct PendingNonceKey(IPEndPoint Endpoint, NonceKey Nonce); + +internal readonly record struct ResponseKey(ValueHash256 NodeId, RequestId RequestId, MessageType MessageType); + +internal readonly record struct SentChallengeExpiry(ChallengeKey Key, long CreatedAtMilliseconds); + +internal readonly record struct NonceKey(ulong Prefix, uint Suffix) +{ + public static NonceKey From(ReadOnlySpan nonce) + => nonce.Length == PacketCodec.NonceSize + ? new( + BinaryPrimitives.ReadUInt64BigEndian(nonce[..sizeof(ulong)]), + BinaryPrimitives.ReadUInt32BigEndian(nonce.Slice(sizeof(ulong), sizeof(uint)))) + : throw new ArgumentException($"Nonce must be {PacketCodec.NonceSize} bytes.", nameof(nonce)); +} + +internal sealed record PendingRequest(Node Receiver, Discv5Message Message); + +internal readonly record struct SentChallenge(byte[] Packet, long CreatedAtMilliseconds); diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/IResponseHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/IResponseHandler.cs new file mode 100644 index 000000000000..f90eb60f6a21 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/IResponseHandler.cs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Network.Discovery.Discv5.Messages; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia.Handlers; + +internal interface IResponseHandler +{ + Task Task { get; } + + MessageType MessageType { get; } + + bool Handle(Discv5Message message); +} + +internal interface IResponseHandler : IResponseHandler where TMessage : Discv5Message +{ + bool Handle(TMessage message); +} + +internal abstract class ResponseHandler(MessageType messageType) : IResponseHandler + where TMessage : Discv5Message +{ + public abstract Task Task { get; } + + public MessageType MessageType { get; } = messageType; + + public bool Handle(Discv5Message message) => message is TMessage typedMessage && Handle(typedMessage); + + public abstract bool Handle(TMessage message); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/NodesResponseHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/NodesResponseHandler.cs new file mode 100644 index 000000000000..8d24847fcc31 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/NodesResponseHandler.cs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Enr; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia.Handlers; + +internal sealed class NodesResponseHandler(Node receiver, Distances requestedDistances, IKademliaDistance distanceCalculator, IDiscv5RecordFilter recordFilter) + : ResponseHandler(MessageType.Nodes), IDisposable +{ + private const int MaxNodesResponseMessages = 16; + private const int MaxNodesResponseRecords = 64; + private const int SeenNodeIdsCapacity = 128; + private const int SeenNodeIdsMask = SeenNodeIdsCapacity - 1; + + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Node[] _nodes = new Node[MaxNodesResponseRecords]; + private readonly Hash256?[] _seenNodeIds = new Hash256?[SeenNodeIdsCapacity]; + private readonly bool _allowNonRoutableRelays = receiver.Address.Address.IsLoopbackOrPrivateOrLinkLocal; + + private readonly Lock _lock = new(); + private bool _done; + private int _totalMessages; + private int _receivedMessages; + private int _nodeCount; + + public override Task Task => _completion.Task; + + public void Dispose() + { + lock (_lock) + { + _done = true; + } + } + + public override bool Handle(NodesMsg nodes) + { + if (nodes.Total <= 0 || nodes.Total > MaxNodesResponseMessages) + { + Complete(); + return true; + } + + bool complete = false; + + lock (_lock) + { + if (_done) + { + return true; + } + + if (_totalMessages != 0 && _totalMessages != nodes.Total) + { + complete = CompleteLocked(); + } + else + { + _totalMessages = nodes.Total; + _receivedMessages++; + + if (_receivedMessages <= nodes.Total) + { + AddRecords(nodes); + } + + if (_receivedMessages >= nodes.Total || _nodeCount >= MaxNodesResponseRecords) + { + complete = CompleteLocked(); + } + } + } + + if (complete) + { + _completion.TrySetResult(); + } + + return true; + } + + public Node[] GetNodes() + { + if (!Task.IsCompleted) + { + throw new InvalidOperationException($"{nameof(GetNodes)} must be called after the response handler completes."); + } + + int nodeCount = _nodeCount; + if (nodeCount == 0) + { + return []; + } + + Node[] nodes = _nodes; + if (nodeCount != nodes.Length) + { + Array.Resize(ref nodes, nodeCount); + } + + return nodes; + } + + private void AddRecords(NodesMsg nodes) + { + for (int i = 0; i < nodes.Records.Count && _nodeCount < MaxNodesResponseRecords; i++) + { + NodeRecord record = nodes.Records[i]; + if (recordFilter.Excludes(record) || + !Node.TryFromDiscoveryEnr(record, out Node? node) || + !DiscoveryV5App.IsDiscoveryAddressAcceptable(node.Address.Address, _allowNonRoutableRelays) || + !TryMarkSeen(node.Id.Hash) || + !MatchesRequestedDistance(node, requestedDistances)) + { + continue; + } + + _nodes[_nodeCount++] = node; + } + } + + private bool TryMarkSeen(Hash256 nodeId) + { + for (int i = 0; i < SeenNodeIdsCapacity; i++) + { + int index = (nodeId.GetHashCode() + i) & SeenNodeIdsMask; + Hash256? current = _seenNodeIds[index]; + if (current is null) + { + _seenNodeIds[index] = nodeId; + return true; + } + + if (current.Equals(nodeId)) + { + return false; + } + } + + return false; + } + + private void Complete() + { + bool complete; + lock (_lock) + { + complete = CompleteLocked(); + } + + if (complete) + { + _completion.TrySetResult(); + } + } + + private bool CompleteLocked() + { + if (_done) + { + return false; + } + + _done = true; + return true; + } + + private bool MatchesRequestedDistance(Node node, Distances requestedDistances) + { + int distance = distanceCalculator.CalculateLogDistance(receiver.Id.Hash, node.Id.Hash); + for (int i = 0; i < requestedDistances.Count; i++) + { + if (requestedDistances[i] == distance) + { + return true; + } + } + + return false; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/PongResponseHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/PongResponseHandler.cs new file mode 100644 index 000000000000..4efa8a489b45 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/Handlers/PongResponseHandler.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia.Handlers; + +internal sealed class PongResponseHandler(Node receiver) : ResponseHandler(MessageType.Pong) +{ + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override Task Task => _completion.Task; + + public override bool Handle(PongMsg message) + { + receiver.ValidatedProtocol = true; + _completion.TrySetResult(); + return true; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IDiscv5RecordFilter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IDiscv5RecordFilter.cs new file mode 100644 index 000000000000..5dd1cc8c70b5 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IDiscv5RecordFilter.cs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Network.Enr; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +/// Protocol-level ENR acceptance policy of a discv5 instance. +public interface IDiscv5RecordFilter +{ + /// Returns whether this discv5 instance must drop the record. + bool Excludes(NodeRecord record); +} + +/// Drops consensus-only ENRs: the execution layer cannot dial beacon nodes over RLPx. +public sealed class ExecutionLayerDiscv5RecordFilter : IDiscv5RecordFilter +{ + public static ExecutionLayerDiscv5RecordFilter Instance { get; } = new(); + + public bool Excludes(NodeRecord record) => DiscoveryV5App.IsConsensusOnlyNodeRecord(record); +} + +/// Accepts every record; used by consensus-layer discovery, which filters by fork digest downstream. +public sealed class AcceptAllDiscv5RecordFilter : IDiscv5RecordFilter +{ + public static AcceptAllDiscv5RecordFilter Instance { get; } = new(); + + public bool Excludes(NodeRecord record) => false; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IKademliaAdapter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IKademliaAdapter.cs new file mode 100644 index 000000000000..051863c52ca1 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/IKademliaAdapter.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +/// +/// Adapts discv5 distance-based FINDNODE requests to the protocol-specific Kademlia routing table. +/// +public interface IKademliaAdapter : IKademliaMessageSender, IAsyncDisposable +{ + /// + /// Gets known nodes at the requested log distances from the local node. + /// + /// The requested XOR log distances. + /// An optional node to exclude from the result. + Node[] GetNodesAtDistances(IEnumerable distances, Node? excluding = null); + + Task RunAsync(CancellationToken token); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaAdapter.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaAdapter.cs new file mode 100644 index 000000000000..fd966efdd21f --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaAdapter.cs @@ -0,0 +1,916 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Runtime.CompilerServices; +using Collections.Pooled; +using Nethermind.Core.Caching; +using Nethermind.Core.Collections; +using Nethermind.Core.Crypto; +using Nethermind.Crypto; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv5.Kademlia.Handlers; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Network.Enr; +using Nethermind.Logging; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +/// +/// Maps discv5 FINDNODE distance requests onto the protocol-specific Kademlia table. +/// +public sealed class KademliaAdapter( + Lazy> kademlia, // Cyclic dependency: Kademlia uses this adapter as its message sender. + NettyDiscoveryV5Handler discoveryHandler, + PacketCodec packetCodec, + INodeRecordProvider nodeRecordProvider, + IDiscoveryConfig discoveryConfig, + ICryptoRandom cryptoRandom, + IKademliaDistance distance, + IDiscv5RecordFilter recordFilter, + ILogManager logManager) : IKademliaAdapter +{ + private const int MaxFindNodeRecords = 16; + private const int MaxEnrsPerNodesMessage = 3; + private const int MaxSessions = 4_096; + private const int MaxSentChallenges = 4_096; + private const int MaxPendingRequests = 4_096; + private const int MaxResponseHandlers = 1_024; + private const int MaxKnownRecords = 16_384; + private const int MaxEndpointChecks = 4_096; + private const int PacketWorkerCount = 4; + private const long SentChallengeTtlMilliseconds = 60_000; + private const long EndpointCheckTtlMilliseconds = 60_000; + private static readonly TimeSpan ChallengeRateLimitWindow = TimeSpan.FromMilliseconds(100); + private const int ChallengeRateLimitBurstPerIp = 16; + private const int ChallengeRateLimitFilterSize = 8_192; + + private readonly TimeSpan _pingTimeout = TimeSpan.FromMilliseconds(discoveryConfig.PingTimeout); + private readonly TimeSpan _findNodeTimeout = TimeSpan.FromMilliseconds(discoveryConfig.SendNodeTimeout); + private readonly IKademliaDistance _distance = distance; + private readonly ILogger _logger = logManager.GetClassLogger(); + private readonly DisposingLruCache _sessions = new(MaxSessions, "discv5 sessions"); + private readonly LruCache _sentChallenges = new(MaxSentChallenges, "discv5 sent challenges"); + private readonly Queue _sentChallengeExpiries = new(); + private readonly Lock _sentChallengeExpiriesLock = new(); + private long _lastSentChallengeTrimMilliseconds; + private readonly LruCache _pendingByNonce = new(MaxPendingRequests, "discv5 pending requests"); + private readonly LruCache _responseHandlers = new(MaxResponseHandlers, "discv5 response handlers"); + private readonly LruCache _knownRecords = new(MaxKnownRecords, "discv5 known records"); + private readonly Lock _knownRecordsLock = new(); + private readonly LruCache _endpointChecks = new(MaxEndpointChecks, "discv5 endpoint checks"); + private readonly AddressBurstLimiter _challengeRateLimiter = new(ChallengeRateLimitBurstPerIp, ChallengeRateLimitFilterSize, ChallengeRateLimitWindow); + + /// + public Node[] GetNodesAtDistances(IEnumerable distances, Node? excluding = null) + { + ArgumentNullException.ThrowIfNull(distances); + + using PooledSet seen = new(MaxFindNodeRecords); + using ArrayPoolListRef result = new(MaxFindNodeRecords); + Hash256? excludedHash = excluding?.IdHash; + + foreach (int distance in distances) + { + if (distance < 0 || distance > _distance.MaxDistance) + { + throw new ArgumentOutOfRangeException(nameof(distances), distance, $"Distance must be between 0 and {_distance.MaxDistance}."); + } + + Node[] nodes = kademlia.Value.GetAllAtDistance(distance); + for (int i = 0; i < nodes.Length; i++) + { + Node node = nodes[i]; + + if (excludedHash is not null && node.IdHash.Equals(excludedHash)) + { + continue; + } + + if (seen.Add(node.IdHash)) + { + result.Add(node); + } + } + } + + return result.Count == 0 ? [] : result.ToArray(); + } + + /// + public async Task Ping(Node receiver, CancellationToken token) + { + RegisterKnownRecord(receiver); + ReserveEndpointCheck(receiver); + using PingMsg ping = new(CreateRequestId(), (await nodeRecordProvider.GetCurrentAsync(token)).EnrSequence); + PongResponseHandler responseHandler = new(receiver); + + if (_logger.IsTrace) _logger.Trace($"Sending discv5 PING {ping.RequestId} to {receiver:s}."); + if (!await SendRequest(receiver, ping, responseHandler, _pingTimeout, token)) + { + if (_logger.IsTrace) _logger.Trace($"Discv5 PING {ping.RequestId} to {receiver:s} timed out."); + return false; + } + + if (_logger.IsTrace) _logger.Trace($"Discv5 PING {ping.RequestId} to {receiver:s} succeeded."); + kademlia.Value.AddOrRefresh(receiver); + return true; + } + + /// + public async Task FindNeighbours(Node receiver, PublicKey target, CancellationToken token) + { + RegisterKnownRecord(receiver); + Distances distances = GetLookupDistances(receiver, target); + using FindNodeMsg findNode = new(CreateRequestId(), distances); + using NodesResponseHandler responseHandler = new(receiver, distances, _distance, recordFilter); + + if (_logger.IsTrace) _logger.Trace($"Sending discv5 FINDNODE {findNode.RequestId} to {receiver:s}, distances: {FormatDistances(distances)}."); + if (!await SendRequest(receiver, findNode, responseHandler, _findNodeTimeout, token)) + { + if (_logger.IsTrace) _logger.Trace($"Discv5 FINDNODE {findNode.RequestId} to {receiver:s} timed out."); + return null; + } + + Node[] nodes = responseHandler.GetNodes(); + for (int i = 0; i < nodes.Length; i++) + { + kademlia.Value.AddOrRefresh(nodes[i]); + } + + if (_logger.IsTrace) _logger.Trace($"Discv5 FINDNODE {findNode.RequestId} to {receiver:s} returned {nodes.Length} nodes."); + return nodes; + } + + public async Task RunAsync(CancellationToken token) + { + Task[] workers = new Task[PacketWorkerCount]; + for (int i = 0; i < workers.Length; i++) + { + workers[i] = RunPacketWorkerAsync(token); + } + + await Task.WhenAll(workers); + } + + private async Task RunPacketWorkerAsync(CancellationToken token) + { + try + { + await foreach (PooledUdpReceiveResult result in discoveryHandler.ReadMessagesAsync(token)) + { + try + { + await HandlePacket(result, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Error handling discv5 packet from {result.RemoteEndPoint}: {e}"); + } + finally + { + result.Dispose(); + } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception e) + { + if (_logger.IsError) _logger.Error("Error in discv5 packet loop", e); + } + } + + /// + public ValueTask DisposeAsync() + { + _sessions.Clear(); + return ValueTask.CompletedTask; + } + + private async Task SendRequest( + Node receiver, + Discv5Message request, + IResponseHandler responseHandler, + TimeSpan timeout, + CancellationToken token) + where TResponse : Discv5Message + { + ResponseKey responseKey = new(receiver.Id.Hash.ValueHash256, request.RequestId, responseHandler.MessageType); + _responseHandlers.Set(responseKey, responseHandler); + + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(timeout); + + PendingNonceKey? pendingNonceKey = null; + try + { + pendingNonceKey = await SendMessage(receiver, request, timeoutCts.Token); + await responseHandler.Task.WaitAsync(timeoutCts.Token); + return true; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested && timeoutCts.IsCancellationRequested) + { + if (_logger.IsTrace) _logger.Trace($"Discv5 request {request.MessageType} {request.RequestId} to {receiver:s} timed out after {timeout}."); + return false; + } + finally + { + _responseHandlers.TryRemove(responseKey, out _); + if (pendingNonceKey is not null) + { + _pendingByNonce.TryRemove(pendingNonceKey.Value, out _); + } + } + } + + private async Task SendMessage(Node receiver, Discv5Message message, CancellationToken token) + { + if (TryEncodeWithExistingSession(receiver, message, out PendingNonceKey pendingNonceKey, out byte[]? packet)) + { + return await SendPendingPacket(receiver, message, pendingNonceKey, packet, hasSession: true, token); + } + + pendingNonceKey = EncodeMessageWithoutSession(receiver, message, out byte[] initialPacket); + return await SendPendingPacket(receiver, message, pendingNonceKey, initialPacket, hasSession: false, token); + } + + [SkipLocalsInit] + private bool TryEncodeWithExistingSession( + Node receiver, + Discv5Message message, + out PendingNonceKey pendingNonceKey, + [NotNullWhen(true)] out byte[]? packet) + { + SessionKey sessionKey = new(receiver.Id.Hash.ValueHash256, receiver.Address); + if (TryGetSession(sessionKey, out Session? session)) + { + Span writeKey = stackalloc byte[Session.KeySize]; + if (session.TryCopyWriteKey(writeKey)) + { + Span sessionNonce = stackalloc byte[PacketCodec.NonceSize]; + session.WriteNextNonce(cryptoRandom, sessionNonce); + pendingNonceKey = new PendingNonceKey(receiver.Address, NonceKey.From(sessionNonce)); + packet = packetCodec.EncodeOrdinary(receiver.Id, writeKey, message, sessionNonce); + return true; + } + } + + pendingNonceKey = default; + packet = null; + return false; + } + + [SkipLocalsInit] + private PendingNonceKey EncodeMessageWithoutSession(Node receiver, Discv5Message message, out byte[] initialPacket) + { + Span nonce = stackalloc byte[PacketCodec.NonceSize]; + cryptoRandom.GenerateRandomBytes(nonce); + Span encryptionKey = stackalloc byte[Session.KeySize]; + cryptoRandom.GenerateRandomBytes(encryptionKey); + PendingNonceKey pendingNonceKey = new(receiver.Address, NonceKey.From(nonce)); + initialPacket = packetCodec.EncodeOrdinary(receiver.Id, encryptionKey, message, nonce); + return pendingNonceKey; + } + + private async Task SendPendingPacket( + Node receiver, + Discv5Message message, + PendingNonceKey pendingNonceKey, + byte[] packet, + bool hasSession, + CancellationToken token) + { + _pendingByNonce.Set(pendingNonceKey, new PendingRequest(receiver, message)); + try + { + if (_logger.IsTrace) _logger.Trace($"Sending discv5 ordinary {message.MessageType} {message.RequestId} to {receiver:s} {(hasSession ? "with existing session" : "without session")}, bytes: {packet.Length}."); + await discoveryHandler.SendAsync(packet, receiver.Address, token); + return pendingNonceKey; + } + catch + { + _pendingByNonce.TryRemove(pendingNonceKey, out _); + throw; + } + } + + private async Task SendResponse(Node receiver, Discv5Message message, CancellationToken token) + { + if (!TryEncodeResponse(receiver, message, out byte[]? packet)) + { + return; + } + + if (_logger.IsTrace) _logger.Trace($"Sending discv5 response {message.MessageType} {message.RequestId} to {receiver:s}, bytes: {packet.Length}."); + await discoveryHandler.SendAsync(packet, receiver.Address, token); + } + + [SkipLocalsInit] + private bool TryEncodeResponse(Node receiver, Discv5Message message, [NotNullWhen(true)] out byte[]? packet) + { + SessionKey sessionKey = new(receiver.Id.Hash.ValueHash256, receiver.Address); + if (!TryGetSession(sessionKey, out Session? session)) + { + packet = null; + return false; + } + + Span writeKey = stackalloc byte[Session.KeySize]; + if (!session.TryCopyWriteKey(writeKey)) + { + packet = null; + return false; + } + + Span nonce = stackalloc byte[PacketCodec.NonceSize]; + session.WriteNextNonce(cryptoRandom, nonce); + packet = packetCodec.EncodeOrdinary(receiver.Id, writeKey, message, nonce); + return true; + } + + private async Task HandlePacket(PooledUdpReceiveResult udpPacket, CancellationToken token) + { + if (!packetCodec.TryDecode(udpPacket.Buffer, out Packet packet)) + { + if (_logger.IsTrace) _logger.Trace($"Dropping undecodable discv5 packet from {udpPacket.RemoteEndPoint}, bytes: {udpPacket.Buffer.Length}."); + return; + } + + using (packet) + { + if (_logger.IsTrace) _logger.Trace($"Received discv5 {packet.Flag} packet from {udpPacket.RemoteEndPoint}, bytes: {udpPacket.Buffer.Length}."); + try + { + switch (packet.Flag) + { + case PacketFlag.WhoAreYou: + await HandleWhoAreYou(udpPacket.RemoteEndPoint, packet, token); + break; + case PacketFlag.Ordinary: + await HandleOrdinary(udpPacket.RemoteEndPoint, packet, token); + break; + case PacketFlag.Handshake: + await HandleHandshake(udpPacket.RemoteEndPoint, packet, token); + break; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception e) + { + if (_logger.IsDebug) _logger.Debug($"Error handling discv5 packet from {udpPacket.RemoteEndPoint}: {e}"); + } + } + } + + private async Task HandleWhoAreYou(IPEndPoint endpoint, Packet packet, CancellationToken token) + { + PendingNonceKey pendingNonceKey = new(endpoint, NonceKey.From(packet.Nonce.Span)); + if (!_pendingByNonce.TryRemove(pendingNonceKey, out PendingRequest? pendingRequest)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 WHOAREYOU from {endpoint}; no pending request for nonce."); + return; + } + + byte[] handshakePacket; + Session session; + ulong requestedEnrSequence; + using (Challenge challenge = packetCodec.DecodeWhoAreYou(in packet)) + { + NodeRecord currentNodeRecord = await nodeRecordProvider.GetCurrentAsync(token); + handshakePacket = packetCodec.EncodeHandshake(pendingRequest.Receiver.Id, challenge, pendingRequest.Message, currentNodeRecord, out session); + requestedEnrSequence = challenge.EnrSequence; + } + + SetSession(new SessionKey(pendingRequest.Receiver.Id.Hash.ValueHash256, endpoint), session); + if (_logger.IsTrace) _logger.Trace($"Sending discv5 HANDSHAKE for {pendingRequest.Message.MessageType} {pendingRequest.Message.RequestId} to {endpoint}, bytes: {handshakePacket.Length}, requested ENR seq: {requestedEnrSequence}."); + await discoveryHandler.SendAsync(handshakePacket, endpoint, token); + } + + private async Task HandleOrdinary(IPEndPoint endpoint, Packet packet, CancellationToken token) + { + if (!PacketCodec.TryGetSourceNodeId(in packet, out ValueHash256 nodeId)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 ordinary packet from {endpoint}; source node id missing."); + return; + } + + SessionKey sessionKey = new(nodeId, endpoint); + if (!TryDecryptOrdinaryMessage(in packet, sessionKey, out Session? session, out Discv5Message? message)) + { + if (_logger.IsTrace) _logger.Trace($"Discv5 ordinary packet from {endpoint} could not be decrypted with an existing session; sending WHOAREYOU."); + await SendWhoAreYou(endpoint, packet, nodeId, token); + return; + } + + try + { + if (_logger.IsTrace) _logger.Trace($"Received discv5 message {message.MessageType} {message.RequestId} from {endpoint}."); + await HandleMessage(session.RemotePublicKey, endpoint, message, token); + } + finally + { + message.Dispose(); + } + } + + [SkipLocalsInit] + private bool TryDecryptOrdinaryMessage(scoped in Packet packet, SessionKey sessionKey, [NotNullWhen(true)] out Session? session, [NotNullWhen(true)] out Discv5Message? message) + { + Span readKey = stackalloc byte[Session.KeySize]; + if (TryGetSession(sessionKey, out session) && + session.TryCopyReadKey(readKey) && + packetCodec.TryDecryptMessage(in packet, readKey, out Discv5Message decodedMessage)) + { + message = decodedMessage; + return true; + } + + message = null; + return false; + } + + private async Task HandleHandshake(IPEndPoint endpoint, Packet packet, CancellationToken token) + { + if (!PacketCodec.TryGetSourceNodeId(in packet, out ValueHash256 nodeId)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 handshake packet from {endpoint}; source node id missing."); + return; + } + + ChallengeKey challengeKey = new(nodeId, endpoint); + if (!_sentChallenges.TryRemove(challengeKey, out SentChallenge sentChallenge)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 handshake packet from {endpoint}; matching challenge missing."); + return; + } + + if (IsExpired(sentChallenge, Environment.TickCount64)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 handshake packet from {endpoint}; matching challenge expired."); + return; + } + + TryGetKnownRecord(nodeId, out NodeRecord? knownRecord); + if (!PacketCodec.TryDecode(sentChallenge.Packet, nodeId.Bytes, out Packet challengePacket)) + { + if (_logger.IsTrace) _logger.Trace($"Unable to decode matching discv5 WHOAREYOU challenge for {endpoint}."); + return; + } + + Session session; + Discv5Message message; + NodeRecord? nodeRecord; + using (challengePacket) + using (Challenge challenge = packetCodec.DecodeWhoAreYou(in challengePacket)) + { + if (!packetCodec.TryDecryptHandshake(in packet, challenge, knownRecord, out session, out message, out nodeRecord)) + { + if (_logger.IsTrace) _logger.Trace($"Unable to decrypt discv5 handshake packet from {endpoint}."); + return; + } + } + + await HandleHandshakeMessage(endpoint, nodeId, session, message, nodeRecord, knownRecord, token); + } + + private async Task SendWhoAreYou(IPEndPoint endpoint, Packet requestPacket, ValueHash256 nodeId, CancellationToken token) + { + ChallengeKey challengeKey = new(nodeId, endpoint); + long now = Environment.TickCount64; + if (_sentChallenges.TryGet(challengeKey, out SentChallenge existingChallenge) && !IsExpired(existingChallenge, now)) + { + if (_logger.IsTrace) _logger.Trace($"Resending discv5 WHOAREYOU challenge to {endpoint}."); + await discoveryHandler.SendAsync(existingChallenge.Packet, endpoint, token); + return; + } + + if (!TryAcceptChallenge(endpoint)) + { + if (_logger.IsDebug) _logger.Debug($"Rate limiting discv5 WHOAREYOU challenge to {endpoint}."); + return; + } + + ulong enrSequence = TryGetKnownRecord(nodeId, out NodeRecord? record) ? record.EnrSequence : 0UL; + byte[] packet = packetCodec.EncodeWhoAreYou(nodeId.Bytes, requestPacket.Nonce.Span, enrSequence); + SetSentChallenge(challengeKey, packet); + if (_logger.IsTrace) _logger.Trace($"Sending discv5 WHOAREYOU challenge to {endpoint}, known ENR seq: {enrSequence}, bytes: {packet.Length}."); + await discoveryHandler.SendAsync(packet, endpoint, token); + } + + private async Task HandleHandshakeMessage( + IPEndPoint endpoint, + ValueHash256 nodeId, + Session session, + Discv5Message message, + NodeRecord? nodeRecord, + NodeRecord? knownRecord, + CancellationToken token) + { + bool sessionStored = false; + try + { + NodeRecord? messageRecord = knownRecord; + if (nodeRecord is not null) + { + if (!HasExpectedNodeId(nodeRecord, nodeId)) + { + if (_logger.IsTrace) _logger.Trace($"Ignoring discv5 handshake ENR from {endpoint}; ENR node id does not match packet source."); + return; + } + + if (IsAcceptableNodeRecord(nodeRecord, nodeId, endpoint.Address.IsLoopbackOrPrivateOrLinkLocal, recordFilter)) + { + TrySetKnownRecord(nodeId, nodeRecord, out NodeRecord currentRecord); + messageRecord = currentRecord; + } + } + + SetSession(new SessionKey(nodeId, endpoint), session); + sessionStored = true; + if (_logger.IsTrace) _logger.Trace($"Received discv5 handshake message {message.MessageType} {message.RequestId} from {endpoint}, ENR included: {nodeRecord is not null}."); + await HandleMessage(session.RemotePublicKey, endpoint, message, token, messageRecord); + } + finally + { + if (!sessionStored) + { + session.Dispose(); + } + + message.Dispose(); + } + } + + private async Task HandleMessage(PublicKey remotePublicKey, IPEndPoint endpoint, Discv5Message message, CancellationToken token, NodeRecord? nodeRecord = null) + { + ValueHash256 remoteNodeId = remotePublicKey.Hash.ValueHash256; + Node remoteNode = new(remotePublicKey, endpoint) + { + Enr = GetKnownEnr(remoteNodeId, nodeRecord) + }; + if (HandleResponse(remoteNodeId, message)) + { + if (_logger.IsTrace) _logger.Trace($"Handled discv5 response {message.MessageType} {message.RequestId} from {endpoint}."); + kademlia.Value.AddOrRefresh(remoteNode); + return; + } + + if (_logger.IsTrace) _logger.Trace($"Handling discv5 request {message.MessageType} {message.RequestId} from {endpoint}."); + switch (message) + { + case PingMsg ping: + using (PongMsg pong = new(ping.RequestId, (await nodeRecordProvider.GetCurrentAsync(token)).EnrSequence, endpoint.Address, endpoint.Port)) + { + await SendResponse(remoteNode, pong, token); + } + + kademlia.Value.AddOrRefresh(remoteNode); + if (!string.IsNullOrEmpty(remoteNode.Enr)) + { + StartEndpointCheck(remoteNode, token); + } + break; + case FindNodeMsg findNode: + await HandleFindNode(remoteNode, findNode, token); + kademlia.Value.AddOrRefresh(remoteNode); + break; + case TalkReqMsg talkReq: + using (TalkRespMsg talkResp = new(talkReq.RequestId, ReadOnlyMemory.Empty)) + { + await SendResponse(remoteNode, talkResp, token); + } + + break; + } + } + + private string? GetKnownEnr(ValueHash256 nodeId, NodeRecord? nodeRecord) + => nodeRecord?.EnrString ?? (_knownRecords.TryGet(nodeId, out NodeRecord? knownRecord) ? knownRecord.EnrString : null); + + private bool HandleResponse(ValueHash256 nodeId, Discv5Message message) + { + ResponseKey responseKey = new(nodeId, message.RequestId, message.MessageType); + return _responseHandlers.TryGet(responseKey, out IResponseHandler? handler) && handler.Handle(message); + } + + private async Task HandleFindNode(Node remoteNode, FindNodeMsg findNode, CancellationToken token) + { + NodeRecord selfRecord = await nodeRecordProvider.GetCurrentAsync(token); + NodeRecord[] records = GetFindNodeRecords(findNode.Distances, remoteNode, selfRecord); + if (records.Length == 0) + { + using NodesMsg emptyResponse = new(findNode.RequestId, 1, []); + await SendResponse(remoteNode, emptyResponse, token); + return; + } + + int total = (records.Length + MaxEnrsPerNodesMessage - 1) / MaxEnrsPerNodesMessage; + for (int i = 0; i < records.Length; i += MaxEnrsPerNodesMessage) + { + int count = Math.Min(MaxEnrsPerNodesMessage, records.Length - i); + ArraySegment chunk = new(records, i, count); + using NodesMsg nodes = new(findNode.RequestId, total, chunk); + await SendResponse(remoteNode, nodes, token); + } + } + + private NodeRecord[] GetFindNodeRecords(Distances distances, Node requester, NodeRecord selfRecord) + { + using PooledSet seen = new(MaxFindNodeRecords); + ArrayPoolListRef result = new(MaxFindNodeRecords); + try + { + bool allowNonRoutableRelays = requester.Address.Address.IsLoopbackOrPrivateOrLinkLocal; + bool includedSelf = false; + for (int i = 0; i < distances.Count && result.Count < MaxFindNodeRecords; i++) + { + int distance = distances[i]; + if (distance < 0 || distance > _distance.MaxDistance) + { + continue; + } + + if (distance == 0) + { + if (!includedSelf) + { + result.Add(selfRecord); + includedSelf = true; + } + + continue; + } + + AddFindNodeRecordsAtDistance(distance, requester, allowNonRoutableRelays, seen, ref result); + } + + return result.Count == 0 ? [] : result.ToArray(); + } + finally + { + result.Dispose(); + } + } + + private void AddFindNodeRecordsAtDistance( + int distance, + Node requester, + bool allowNonRoutableRelays, + PooledSet seen, + ref ArrayPoolListRef result) + { + Node[] nodes = kademlia.Value.GetAllAtDistance(distance); + Hash256 requesterHash = requester.IdHash; + for (int i = 0; i < nodes.Length && result.Count < MaxFindNodeRecords; i++) + { + Node node = nodes[i]; + + if (node.IdHash.Equals(requesterHash) || string.IsNullOrEmpty(node.Enr) || !seen.Add(node.Id.Hash)) + { + continue; + } + + NodeRecord? record = GetFindNodeRecord(node, allowNonRoutableRelays); + if (record is not null) + { + result.Add(record); + } + } + } + + private NodeRecord? GetFindNodeRecord(Node node, bool allowNonRoutableRelays) + { + if (TryGetKnownRecord(node.Id.Hash, out NodeRecord? knownRecord)) + { + return IsAcceptableNodeRecord(knownRecord, node.Id.Hash, allowNonRoutableRelays, recordFilter) ? knownRecord : null; + } + + try + { + NodeRecord record = NodeRecord.FromEnrString(node.Enr); + return IsAcceptableNodeRecord(record, node.Id.Hash, allowNonRoutableRelays, recordFilter) ? record : null; + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Unable to parse discv5 FINDNODE ENR for {node}: {e}"); + return null; + } + } + + private void RegisterKnownRecord(Node node) + { + if (string.IsNullOrEmpty(node.Enr)) + { + return; + } + + ValueHash256 nodeId = node.Id.Hash.ValueHash256; + if (TryGetKnownRecord(nodeId, out NodeRecord? knownRecord) && + knownRecord.EnrString == node.Enr) + { + return; + } + + try + { + NodeRecord record = NodeRecord.FromEnrString(node.Enr); + if (IsAcceptableNodeRecord(record, node.Id.Hash, node.Address.Address.IsLoopbackOrPrivateOrLinkLocal, recordFilter)) + { + TrySetKnownRecord(node.Id.Hash, record, out _); + } + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Unable to parse known discv5 ENR for {node}: {e}"); + } + } + + [SkipLocalsInit] + internal Distances GetLookupDistances(Node receiver, PublicKey target) + { + int distance = _distance.CalculateLogDistance(receiver.Id.Hash, target.Hash); + + Span distances = stackalloc int[3]; + distances[0] = distance; + int count = 1; + if (distance > 0) + { + distances[count++] = distance - 1; + } + + if (distance < _distance.MaxDistance) + { + distances[count++] = distance + 1; + } + + return new Distances(distances[..count]); + } + + [SkipLocalsInit] + private static string FormatDistances(Distances distances) + { + Span chars = stackalloc char[16]; + int position = 0; + for (int i = 0; i < distances.Count; i++) + { + if (i > 0) + { + chars[position++] = ','; + } + + if (!distances[i].TryFormat(chars[position..], out int written)) + { + return string.Join(",", distances); + } + + position += written; + } + + return chars[..position].ToString(); + } + + [SkipLocalsInit] + private RequestId CreateRequestId() + { + Span requestId = stackalloc byte[sizeof(ulong)]; + cryptoRandom.GenerateRandomBytes(requestId); + int start = 0; + while (start < requestId.Length && requestId[start] == 0) + { + start++; + } + + return RequestId.From(requestId[start..]); + } + + private bool TryGetSession(SessionKey sessionKey, [NotNullWhen(true)] out Session? session) => _sessions.TryGet(sessionKey, out session); + + private void SetSession(SessionKey sessionKey, Session session) + => _sessions.Set(sessionKey, session); + + private bool TryGetKnownRecord(ValueHash256 nodeId, [NotNullWhen(true)] out NodeRecord? record) => _knownRecords.TryGet(nodeId, out record); + + internal bool TrySetKnownRecord(ValueHash256 nodeId, NodeRecord record, out NodeRecord currentRecord) + { + lock (_knownRecordsLock) + { + if (_knownRecords.TryGet(nodeId, out NodeRecord? knownRecord) && knownRecord.EnrSequence >= record.EnrSequence) + { + currentRecord = knownRecord; + return false; + } + + _knownRecords.Set(nodeId, record); + currentRecord = record; + return true; + } + } + + internal static bool IsAcceptableNodeRecord(NodeRecord record, ValueHash256 expectedNodeId, bool allowNonRoutable, IDiscv5RecordFilter recordFilter) + => !recordFilter.Excludes(record) && + Node.TryFromDiscoveryEnr(record, out Node? node) && + node.Id.Hash == expectedNodeId && + DiscoveryV5App.IsDiscoveryAddressAcceptable(node.Address.Address, allowNonRoutable); + + internal static bool HasExpectedNodeId(NodeRecord record, ValueHash256 expectedNodeId) + => record.GetObj(EnrContentKey.SecP256k1)?.Decompress().Hash == expectedNodeId; + + private void SetSentChallenge(ChallengeKey challengeKey, byte[] packet) + { + long now = Environment.TickCount64; + TryTrimExpiredChallenges(now); + _sentChallenges.Set(challengeKey, new SentChallenge(packet, now)); + lock (_sentChallengeExpiriesLock) + { + _sentChallengeExpiries.Enqueue(new SentChallengeExpiry(challengeKey, now)); + } + } + + private void TryTrimExpiredChallenges(long now) + { + long lastTrim = Volatile.Read(ref _lastSentChallengeTrimMilliseconds); + if (now - lastTrim <= SentChallengeTtlMilliseconds || + Interlocked.CompareExchange(ref _lastSentChallengeTrimMilliseconds, now, lastTrim) != lastTrim) + { + return; + } + + TrimExpiredChallenges(now); + } + + private void TrimExpiredChallenges(long now) + { + lock (_sentChallengeExpiriesLock) + { + while (_sentChallengeExpiries.TryPeek(out SentChallengeExpiry expiry) && + now - expiry.CreatedAtMilliseconds > SentChallengeTtlMilliseconds) + { + _sentChallengeExpiries.Dequeue(); + if (_sentChallenges.TryGet(expiry.Key, out SentChallenge challenge) && + challenge.CreatedAtMilliseconds == expiry.CreatedAtMilliseconds) + { + _sentChallenges.TryRemove(expiry.Key, out _); + } + } + } + } + + private static bool IsExpired(SentChallenge challenge, long now) + => now - challenge.CreatedAtMilliseconds > SentChallengeTtlMilliseconds; + + internal bool TryAcceptChallenge(IPEndPoint endpoint) + => _challengeRateLimiter.TryAccept(endpoint.Address); + + private void StartEndpointCheck(Node remoteNode, CancellationToken token) + { + if (!TryReserveEndpointCheck(remoteNode)) + { + return; + } + + _ = RunEndpointCheck(remoteNode, token); + } + + private async Task RunEndpointCheck(Node remoteNode, CancellationToken token) + { + try + { + _ = await Ping(remoteNode, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Discv5 endpoint check failed for {remoteNode}: {e}"); + } + } + + private void ReserveEndpointCheck(Node remoteNode) + => _endpointChecks.Set(new SessionKey(remoteNode.Id.Hash.ValueHash256, remoteNode.Address), Environment.TickCount64); + + private bool TryReserveEndpointCheck(Node remoteNode) + { + SessionKey sessionKey = new(remoteNode.Id.Hash.ValueHash256, remoteNode.Address); + long now = Environment.TickCount64; + if (_endpointChecks.TryGet(sessionKey, out long startedAt) && + now - startedAt <= EndpointCheckTtlMilliseconds) + { + return false; + } + + _endpointChecks.Set(sessionKey, now); + return true; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaModule.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaModule.cs new file mode 100644 index 000000000000..1ae3bbd83f7a --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/KademliaModule.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Autofac; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Db; +using Nethermind.Kademlia; +using Nethermind.Network.Discovery.Discv5.Packets; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +/// +/// Specifies the protocol-specific Kademlia services used by discv5. +/// +public sealed class KademliaModule(Node currentNode, IReadOnlyList bootNodes) : DiscoveryKademliaModuleBase(currentNode, bootNodes, DbNames.DiscoveryV5Nodes) +{ + protected override void RegisterProtocolServices(ContainerBuilder builder) => builder + .AddSingleton(ExecutionLayerDiscv5RecordFilter.Instance) + .AddSingleton() + .AddSingleton() + .Bind, IKademliaAdapter>() + .AddSingleton() + .AddSingleton(); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/NodeSource.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/NodeSource.cs new file mode 100644 index 000000000000..99561db80e59 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Kademlia/NodeSource.cs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Network.Enr; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Discv5.Kademlia; + +public sealed class NodeSource( + IKademlia kademlia, + IKademliaDiscovery kademliaDiscovery, + IDiscoveryConfig discoveryConfig, + KademliaConfig kademliaConfig, + IDiscv5RecordFilter recordFilter, + ILogManager logManager) + : IKademliaNodeSource +{ + private const int ChannelCapacity = 64; + + private readonly ILogger _logger = logManager.GetClassLogger(); + private readonly Hash256 _currentNodeHash = kademliaConfig.CurrentNodeId.IdHash; + private readonly int _recentNodeLimit = RecentNodeFilter.GetLimit(kademliaConfig.KSize, Hash256KademliaDistance.Instance.MaxDistance, ChannelCapacity); + + public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] CancellationToken token) + { + if (_logger.IsDebug) _logger.Debug("Starting discv5 node source"); + + Channel channel = Channel.CreateBounded(ChannelCapacity); + RecentNodeFilter recentlyWrittenNodes = new(_recentNodeLimit); + int initialNodes = 0; + using CancellationTokenSource disposeCts = CancellationTokenSource.CreateLinkedTokenSource(token); + CancellationToken discoveryToken = disposeCts.Token; + + foreach (Node node in kademlia.IterateNodes()) + { + if (!IsExcluded(node) && + TryCreatePeerCandidate(node, out Node? peerCandidate) && + recentlyWrittenNodes.TryReserve(peerCandidate.IdHash)) + { + initialNodes++; + yield return peerCandidate; + } + } + + if (_logger.IsDebug) _logger.Debug($"Discv5 node source emitted {initialNodes} initial nodes from the routing table."); + + Task discoverTask = DiscoverAsync(); + kademlia.OnNodeAdded += Handler; + try + { + await foreach (Node node in channel.Reader.ReadAllAsync(token)) + { + yield return node; + } + } + finally + { + kademlia.OnNodeAdded -= Handler; + await disposeCts.CancelAsync(); + channel.Writer.TryComplete(); + try + { + await discoverTask; + } + catch (OperationCanceledException) when (discoveryToken.IsCancellationRequested) + { + } + } + + async Task DiscoverAsync() + { + try + { + await foreach (Node node in kademliaDiscovery.DiscoverNodes(discoveryConfig.ConcurrentDiscoveryJob, ChannelCapacity, discoveryToken)) + { + if (!TryReservePeerCandidate(node, out Node? peerCandidate)) + { + continue; + } + + try + { + await channel.Writer.WriteAsync(peerCandidate, discoveryToken); + } + catch + { + recentlyWrittenNodes.Release(peerCandidate.IdHash); + throw; + } + } + } + catch (OperationCanceledException) when (discoveryToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + if (_logger.IsError) _logger.Error("Discv5 Kademlia discovery node stream failed.", ex); + } + } + + void Handler(object? _, Node node) + { + if (!TryReservePeerCandidate(node, out Node? peerCandidate)) + { + return; + } + + if (channel.Writer.TryWrite(peerCandidate)) + { + if (_logger.IsDebug) _logger.Debug($"Discv5 node source queued discovered node {peerCandidate:s}."); + return; + } + + recentlyWrittenNodes.Release(peerCandidate.IdHash); + if (_logger.IsTrace) + { + _logger.Trace($"Discv5 node source queue is full, dropping discovered node {node:s}."); + } + } + + bool TryReservePeerCandidate(Node node, [NotNullWhen(true)] out Node? peerCandidate) + { + peerCandidate = null; + if (IsExcluded(node) || + !TryCreatePeerCandidate(node, out Node? candidate) || + !recentlyWrittenNodes.TryReserve(candidate.IdHash)) + { + return false; + } + + peerCandidate = candidate; + return true; + } + } + + private bool IsExcluded(Node node) => node.IsBootnode || node.IdHash.Equals(_currentNodeHash); + + private bool TryCreatePeerCandidate(Node discoveryNode, [NotNullWhen(true)] out Node? peerCandidate) + { + peerCandidate = null; + if (string.IsNullOrEmpty(discoveryNode.Enr)) + { + return false; + } + + try + { + NodeRecord record = NodeRecord.FromEnrString(discoveryNode.Enr); + if (recordFilter.Excludes(record)) + { + return false; + } + + return Node.TryFromEnr(record, out peerCandidate); + } + catch (Exception e) + { + if (_logger.IsTrace) _logger.Trace($"Unable to parse discv5 discovered ENR for {discoveryNode}: {e}"); + return false; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/MessageCodec.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/MessageCodec.cs new file mode 100644 index 000000000000..2b71685fd6df --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/MessageCodec.cs @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Discv5.Serializers; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5; + +internal static class MessageCodec +{ + private static readonly IMsgSerializer?[] Serializers = [null, + new PingMsgSerializer(), + new PongMsgSerializer(), + new FindNodeMsgSerializer(), + new NodesMsgSerializer(), + new TalkReqMsgSerializer(), + new TalkRespMsgSerializer(), + ]; + + public static NettyRlpStream Encode(Discv5Message message) + { + IMsgSerializer serializer = GetSerializer(message.MessageType); + int contentLength = serializer.GetContentLength(message); + NettyRlpStream stream = new(NethermindBuffers.Default.Buffer(Rlp.LengthOfSequence(contentLength) + 1)); + + try + { + stream.WriteByte((byte)message.MessageType); + stream.StartSequence(contentLength); + serializer.Serialize(stream, message); + } + catch + { + stream.Dispose(); + throw; + } + + return stream; + } + + public static Discv5Message Decode(ReadOnlySpan message) + => RequiresOwnedMessage(message) + ? throw new RlpException("discv5 TALK messages require owned message memory. Use DecodeOwned.") + : Decode(message, default, null); + + public static Discv5Message DecodeOwned(ReadOnlyMemory message, ArrayPoolSpan owner) + => Decode(message.Span, message, owner); + + private static Discv5Message Decode(ReadOnlySpan message, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + { + if (message.IsEmpty) + { + DisposeOwner(owner); + throw new RlpException("Empty discv5 message."); + } + + Discv5Message? decoded = null; + try + { + MessageType messageType = (MessageType)message[0]; + IMsgSerializer serializer = GetSerializer(messageType); + Rlp.ValueDecoderContext ctx = new(message[1..]); + int checkPosition = ctx.ReadSequenceLength() + ctx.Position; + + decoded = serializer.Deserialize(ref ctx, ownedMessage, owner); + ctx.Check(checkPosition); + ctx.CheckEnd(); + return decoded; + } + catch + { + if (decoded is not null) + { + decoded.Dispose(); + } + else + { + DisposeOwner(owner); + } + + throw; + } + } + + private static IMsgSerializer GetSerializer(MessageType messageType) + { + int type = (byte)messageType; + if ((uint)type < (uint)Serializers.Length && Serializers[type] is { } serializer) + { + return serializer; + } + + throw new RlpException($"Unsupported discv5 message type {(byte)messageType}."); + } + + private static void DisposeOwner(ArrayPoolSpan? owner) + { + if (owner is { } ownerValue) + { + ownerValue.Dispose(); + } + } + + private static bool RequiresOwnedMessage(ReadOnlySpan message) + => !message.IsEmpty + && message[0] < Serializers.Length + && Serializers[message[0]] is { RequiresOwnedMemory: true }; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Discv5Message.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Discv5Message.cs new file mode 100644 index 000000000000..0a33754b5230 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Discv5Message.cs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal abstract record Discv5Message : IDisposable +{ + public abstract MessageType MessageType { get; } + + public RequestId RequestId { get; } + + private ArrayPoolSpan _owner; + private bool _hasOwner; + + protected Discv5Message(in RequestId requestId, ArrayPoolSpan? owner = null) + { + RequestId = requestId; + if (owner is { } ownerValue) + { + _owner = ownerValue; + _hasOwner = true; + } + } + + public void Dispose() + { + DisposeCore(); + if (_hasOwner) + { + _owner.Dispose(); + _hasOwner = false; + } + } + + protected virtual void DisposeCore() + { + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Distances.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Distances.cs new file mode 100644 index 000000000000..52f61d4d3421 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/Distances.cs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Collections; +using System.Runtime.CompilerServices; +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed class Distances : IReadOnlyList, IDisposable +{ + internal const int MaxCount = 257; + private const int InlineCapacity = 3; + + private int[]? _rented; + private InlineDistances _inline; + + public Distances(ReadOnlySpan distances) + : this(distances.Length) + { + for (int i = 0; i < distances.Length; i++) + { + Set(i, distances[i]); + } + } + + internal Distances(int count) + { + if ((uint)count > MaxCount) + { + throw new ArgumentOutOfRangeException(nameof(count), count, $"Distance count must be between 0 and {MaxCount}."); + } + + Count = count; + if (count > InlineCapacity) + { + _rented = SafeArrayPool.Shared.Rent(count); + } + } + + public int Count { get; } + + public int this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Count, nameof(index)); + + if (_rented is not null) + { + return _rented[index]; + } + + return _inline[index]; + } + } + + public void Dispose() + { + if (_rented is not null) + { + SafeArrayPool.Shared.Return(_rented); + _rented = null; + } + } + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < Count; i++) + { + yield return this[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal void Set(int index, int value) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Count, nameof(index)); + + if (_rented is not null) + { + _rented[index] = value; + return; + } + + _inline[index] = value; + } + + [InlineArray(InlineCapacity)] + private struct InlineDistances + { + private int _element0; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/FindNodeMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/FindNodeMsg.cs new file mode 100644 index 000000000000..602ba6644568 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/FindNodeMsg.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record FindNodeMsg : Discv5Message +{ + public FindNodeMsg(ReadOnlySpan requestId, ReadOnlySpan distances) + : this(RequestId.From(requestId), new Distances(distances)) + { + } + + public FindNodeMsg(in RequestId requestId, Distances distances, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + => Distances = distances; + + public override MessageType MessageType => MessageType.FindNode; + + public Distances Distances { get; } + + protected override void DisposeCore() => Distances.Dispose(); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/MessageType.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/MessageType.cs new file mode 100644 index 000000000000..d8059c7fb58b --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/MessageType.cs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal enum MessageType : byte +{ + Ping = 0x01, + Pong = 0x02, + FindNode = 0x03, + Nodes = 0x04, + TalkReq = 0x05, + TalkResp = 0x06 +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/NodesMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/NodesMsg.cs new file mode 100644 index 000000000000..197691adfdb5 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/NodesMsg.cs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Enr; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record NodesMsg : Discv5Message +{ + public NodesMsg(ReadOnlySpan requestId, int total, IReadOnlyList records) + : this(RequestId.From(requestId), total, records) + { + } + + public NodesMsg(in RequestId requestId, int total, IReadOnlyList records, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + { + Total = total; + Records = records; + } + + public override MessageType MessageType => MessageType.Nodes; + + public int Total { get; } + + public IReadOnlyList Records { get; } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PingMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PingMsg.cs new file mode 100644 index 000000000000..fb2bdb794158 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PingMsg.cs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record PingMsg : Discv5Message +{ + public PingMsg(ReadOnlySpan requestId, ulong enrSequence) + : this(RequestId.From(requestId), enrSequence) + { + } + + public PingMsg(in RequestId requestId, ulong enrSequence, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + => EnrSequence = enrSequence; + + public override MessageType MessageType => MessageType.Ping; + + public ulong EnrSequence { get; } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PongMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PongMsg.cs new file mode 100644 index 000000000000..9d1a57da02e1 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/PongMsg.cs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record PongMsg : Discv5Message +{ + public PongMsg(ReadOnlySpan requestId, ulong enrSequence, IPAddress recipientIp, int recipientPort) + : this(RequestId.From(requestId), enrSequence, recipientIp, recipientPort) + { + } + + public PongMsg(in RequestId requestId, ulong enrSequence, IPAddress recipientIp, int recipientPort, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + { + EnrSequence = enrSequence; + RecipientIp = recipientIp; + RecipientPort = recipientPort; + } + + public override MessageType MessageType => MessageType.Pong; + + public ulong EnrSequence { get; } + + public IPAddress RecipientIp { get; } + + public int RecipientPort { get; } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/RequestId.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/RequestId.cs new file mode 100644 index 000000000000..71ca168abfde --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/RequestId.cs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal readonly record struct RequestId(ulong Value, byte Length) +{ + public const int MaxLength = sizeof(ulong); + + public static RequestId From(ReadOnlySpan requestId) + { + if (requestId.Length > MaxLength) + { + throw new ArgumentOutOfRangeException(nameof(requestId), requestId.Length, $"discv5 request-id length exceeds {MaxLength}."); + } + + ulong value = 0; + for (int i = 0; i < requestId.Length; i++) + { + value = (value << 8) | requestId[i]; + } + + return new RequestId(value, checked((byte)requestId.Length)); + } + + public void CopyTo(Span destination) + { + ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Length, nameof(destination)); + + ulong value = Value; + for (int i = Length - 1; i >= 0; i--) + { + destination[i] = (byte)value; + value >>= 8; + } + } + + public int GetRlpLength() + { + byte firstByte = Length == 0 ? (byte)0 : (byte)(Value >> ((Length - 1) * 8)); + return Rlp.LengthOfByteString(Length, firstByte); + } + +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkReqMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkReqMsg.cs new file mode 100644 index 000000000000..97ff45528430 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkReqMsg.cs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record TalkReqMsg : Discv5Message +{ + public TalkReqMsg(ReadOnlySpan requestId, ReadOnlyMemory protocol, ReadOnlyMemory request) + : this(RequestId.From(requestId), protocol, request) + { + } + + public TalkReqMsg(in RequestId requestId, ReadOnlyMemory protocol, ReadOnlyMemory request, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + { + _protocol = protocol; + _request = request; + } + + public override MessageType MessageType => MessageType.TalkReq; + + private readonly ReadOnlyMemory _protocol; + + private readonly ReadOnlyMemory _request; + + internal ReadOnlySpan Protocol => _protocol.Span; + + internal ReadOnlySpan Request => _request.Span; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkRespMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkRespMsg.cs new file mode 100644 index 000000000000..ca594da9b87e --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Messages/TalkRespMsg.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Messages; + +internal sealed record TalkRespMsg : Discv5Message +{ + public TalkRespMsg(ReadOnlySpan requestId, ReadOnlyMemory response) + : this(RequestId.From(requestId), response) + { + } + + public TalkRespMsg(in RequestId requestId, ReadOnlyMemory response, ArrayPoolSpan? owner = null) + : base(in requestId, owner) + => _response = response; + + public override MessageType MessageType => MessageType.TalkResp; + + private readonly ReadOnlyMemory _response; + + internal ReadOnlySpan Response => _response.Span; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NettyDiscoveryV5Handler.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/NettyDiscoveryV5Handler.cs index 17f8a2616bde..c4658829761b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NettyDiscoveryV5Handler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/NettyDiscoveryV5Handler.cs @@ -1,52 +1,63 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Threading.Channels; using DotNetty.Buffers; +using DotNetty.Common.Utilities; using DotNetty.Transport.Channels; using DotNetty.Transport.Channels.Sockets; -using Lantern.Discv5.WireProtocol.Connection; using Microsoft.Extensions.DependencyInjection; +using Nethermind.Core.Collections; using Nethermind.Logging; -using Nethermind.Serialization.Rlp; namespace Nethermind.Network.Discovery.Discv5; /// -/// Adapter, integrating DotNetty externally-managed with Lantern.Discv5 +/// DotNetty UDP bridge used by the native discv5 implementation. /// -public class NettyDiscoveryV5Handler(ILogManager loggerManager) : NettyDiscoveryBaseHandler(loggerManager), IUdpConnection +public sealed class NettyDiscoveryV5Handler(ILogManager loggerManager, IChannel? channel = null) : NettyDiscoveryBaseHandler(loggerManager, channel) { private const int MaxMessagesBuffered = 1024; private readonly ILogger _logger = loggerManager.GetClassLogger(); - private readonly Channel _inboundQueue = Channel.CreateBounded(MaxMessagesBuffered); + private readonly Channel _inboundQueue = System.Threading.Channels.Channel.CreateBounded(MaxMessagesBuffered); - private IChannel? _nettyChannel; + private int _activeReaders; - public void InitializeChannel(IChannel channel) => _nettyChannel = channel; + protected override void CloseInbound() => Close(); protected override void ChannelRead0(IChannelHandlerContext ctx, DatagramPacket msg) { - UdpReceiveResult udpPacket = new(msg.Content.ReadAllBytesAsArray(), (IPEndPoint)msg.Sender); + msg.Retain(); + DatagramPacket queuedPacket = msg; - if (!_inboundQueue.Writer.TryWrite(udpPacket) && _logger.IsDebug) + if (_inboundQueue.Writer.TryWrite(queuedPacket)) + { + if (_logger.IsTrace) _logger.Trace($"Queued discv5 UDP packet from {NormalizeEndpoint((IPEndPoint)msg.Sender)}, bytes: {msg.Content.ReadableBytes}."); + return; + } + + ReferenceCountUtil.Release(queuedPacket); + if (_logger.IsWarn) { _logger.Warn("Skipping discovery v5 message as inbound buffer is full"); } } - public async Task SendAsync(byte[] data, IPEndPoint destination) + public async Task SendAsync(byte[] data, IPEndPoint destination, CancellationToken token) { - if (_nettyChannel == null) throw new("Channel for discovery v5 is not initialized"); + token.ThrowIfCancellationRequested(); DatagramPacket packet = new(Unpooled.WrappedBuffer(data), destination); try { - await _nettyChannel.WriteAndFlushAsync(packet); + if (_logger.IsTrace) _logger.Trace($"Sending discv5 UDP packet to {destination}, bytes: {data.Length}."); + await Channel.WriteAndFlushAsync(packet).WaitAsync(token); } catch (SocketException exception) { @@ -55,13 +66,82 @@ public async Task SendAsync(byte[] data, IPEndPoint destination) } } - public IAsyncEnumerable ReadMessagesAsync(CancellationToken token = default) => - _inboundQueue.Reader.ReadAllAsync(token); + internal async IAsyncEnumerable ReadMessagesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default) + { + Interlocked.Increment(ref _activeReaders); + try + { + await foreach (DatagramPacket packet in _inboundQueue.Reader.ReadAllAsync(token)) + { + try + { + yield return CreateReceiveResult(packet); + } + finally + { + ReferenceCountUtil.Release(packet); + } + } + } + finally + { + if (Interlocked.Decrement(ref _activeReaders) == 0) + { + ReleaseQueuedPackets(); + } + } + } + + private static PooledUdpReceiveResult CreateReceiveResult(DatagramPacket packet) + { + IByteBuffer content = packet.Content; + int readerIndex = content.ReaderIndex; + int readableBytes = content.ReadableBytes; + ArrayPoolSpan buffer = new(readableBytes); + try + { + if (!MemoryMarshal.TryGetArray(buffer.AsMemory(), out ArraySegment segment)) + { + ThrowMissingArraySegment(); + } + + content.GetBytes(readerIndex, segment.Array!, segment.Offset, readableBytes); + content.SetReaderIndex(readerIndex + readableBytes); + + return new PooledUdpReceiveResult(NormalizeEndpoint((IPEndPoint)packet.Sender), buffer); + } + catch + { + buffer.Dispose(); + throw; + } + + [DoesNotReturn] + static void ThrowMissingArraySegment() + => throw new InvalidOperationException("Pooled UDP receive buffer must be array-backed."); + } + + private static IPEndPoint NormalizeEndpoint(IPEndPoint endpoint) + => endpoint.Address.IsIPv4MappedToIPv6 + ? new IPEndPoint(endpoint.Address.MapToIPv4(), endpoint.Port) + : endpoint; + + public void Close() + { + _inboundQueue.Writer.TryComplete(); + if (Volatile.Read(ref _activeReaders) == 0) + { + ReleaseQueuedPackets(); + } + } - public Task ListenAsync(CancellationToken token = default) => Task.CompletedTask; - public void Close() => _inboundQueue.Writer.Complete(); + private void ReleaseQueuedPackets() + { + while (_inboundQueue.Reader.TryRead(out DatagramPacket? packet)) + { + ReferenceCountUtil.Release(packet); + } + } - public static void Register(IServiceCollection services) => services - .AddSingleton() - .AddSingleton(static p => p.GetRequiredService()); + public static void Register(IServiceCollection services) => services.AddSingleton(); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NetworkNodeExtensions.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/NetworkNodeExtensions.cs deleted file mode 100644 index 62053cb01754..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/NetworkNodeExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Lantern.Discv5.Enr; -using Lantern.Discv5.Enr.Entries; -using Lantern.Discv5.Enr.Identity; -using NBitcoin.Secp256k1; -using Nethermind.Config; - -namespace Nethermind.Network.Discovery.Discv5; - -public static class NetworkNodeExtensions -{ - public static Lantern.Discv5.Enr.Enr ToEnr(this NetworkNode node, IIdentityVerifier verifier, IIdentitySigner signer) - { - if (node.IsEnr) return node.Enr; - - Enode enode = node.Enode; - return new EnrBuilder() - .WithIdentityScheme(verifier, signer) - .WithEntry(EnrEntryKey.Id, new EntryId("v4")) - .WithEntry(EnrEntryKey.Ip, new EntryIp(enode.HostIp)) - .WithEntry(EnrEntryKey.Secp256K1, new EntrySecp256K1(Context.Instance.CreatePubKey(enode.PublicKey.PrefixedBytes).ToBytes(false))) - .WithEntry(EnrEntryKey.Tcp, new EntryTcp(enode.Port)) - .WithEntry(EnrEntryKey.Udp, new EntryUdp(enode.DiscoveryPort)) - .Build(); - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Challenge.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Challenge.cs new file mode 100644 index 000000000000..cc25044f1602 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Challenge.cs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Security.Cryptography; + +namespace Nethermind.Network.Discovery.Discv5.Packets; + +internal sealed class Challenge : IDisposable +{ + private readonly ReadOnlyMemory _challengeData; + private readonly byte[]? _ownedChallengeData; + + public Challenge(ulong enrSequence, ReadOnlyMemory challengeData) + { + EnrSequence = enrSequence; + _challengeData = challengeData; + } + + public Challenge(ulong enrSequence, byte[] ownedChallengeData) + { + EnrSequence = enrSequence; + _ownedChallengeData = ownedChallengeData; + _challengeData = ownedChallengeData; + } + + public ulong EnrSequence { get; } + + public ReadOnlySpan ChallengeData => _challengeData.Span; + + public void Dispose() + { + if (_ownedChallengeData is not null) + { + CryptographicOperations.ZeroMemory(_ownedChallengeData); + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Packet.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Packet.cs new file mode 100644 index 000000000000..458e409d8958 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Packet.cs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5.Packets; + +internal readonly struct Packet( + PacketFlag flag, + ReadOnlyMemory nonce, + ReadOnlyMemory authData, + ReadOnlyMemory message, + byte[] messageAdBuffer, + int messageAdLength) : IDisposable +{ + private readonly byte[]? _messageAdBuffer = messageAdBuffer; + + public PacketFlag Flag { get; } = flag; + + public ReadOnlyMemory Nonce { get; } = nonce; + + public ReadOnlyMemory AuthData { get; } = authData; + + public ReadOnlyMemory Message { get; } = message; + + public ReadOnlyMemory MessageAd { get; } = messageAdBuffer.AsMemory(0, messageAdLength); + + public ReadOnlyMemory ChallengeData => MessageAd; + + public void Dispose() + { + if (_messageAdBuffer is null) + { + return; + } + + SafeArrayPool.Shared.Return(_messageAdBuffer); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketCodec.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketCodec.cs new file mode 100644 index 000000000000..7ac197a21506 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketCodec.cs @@ -0,0 +1,695 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using Autofac.Features.AttributeFilters; +using Microsoft.Extensions.ObjectPool; +using Nethermind.Core.Collections; +using Nethermind.Core.Crypto; +using Nethermind.Crypto; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Enr; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Packets; + +public sealed class PacketCodec( + [KeyFilter(IProtectedPrivateKey.NodeKey)] IProtectedPrivateKey nodeKey, + ICryptoRandom cryptoRandom, + IEcdsa ecdsa) : IDisposable +{ + public const int NonceSize = 12; + + internal const int MaxPacketSize = 1280; + + private const int MaskingIvSize = 16; + private const int StaticHeaderSize = 23; + private const int NodeIdSize = 32; + private const int WhoAreYouAuthDataSize = 24; + private const int IdNonceSize = 16; + private const int AesKeySize = 16; + private const int AesGcmTagSize = 16; + private const int Version = 1; + private const int IdSignatureSize = 64; + private const int EphemeralPublicKeySize = 33; + private const int HandshakeAuthDataHeadSize = NodeIdSize + 2; + private const int MaxStackPacketBufferSize = 512; + private const int MaxRetainedDecodeMaskingAes = 32; + + private static ReadOnlySpan ProtocolId => "discv5"u8; + private static ReadOnlySpan KeyAgreementInfoPrefix => "discovery v5 key agreement"u8; + private static ReadOnlySpan IdentityProofText => "discovery v5 identity proof"u8; + + private readonly PrivateKey _privateKey = nodeKey.Unprotect(); + private readonly PublicKey _publicKey = nodeKey.PublicKey; + private readonly ValueHash256 _localNodeId = nodeKey.PublicKey.Hash.ValueHash256; + private readonly ICryptoRandom _cryptoRandom = cryptoRandom; + private readonly IEcdsa _ecdsa = ecdsa; + private readonly ObjectPool _decodeMaskingAesPool = CreateDecodeMaskingAesPool(nodeKey.PublicKey.Hash.Bytes[..AesKeySize]); + + public void Dispose() + { + (_decodeMaskingAesPool as IDisposable)?.Dispose(); + _privateKey.Dispose(); + } + + internal byte[] EncodeOrdinary(PublicKey destination, ReadOnlySpan encryptionKey, Discv5Message message, ReadOnlySpan nonce) + => EncodePacket(destination.Hash.Bytes, PacketFlag.Ordinary, nonce, _localNodeId.Bytes, encryptionKey, message); + + [SkipLocalsInit] + internal byte[] EncodeWhoAreYou(ReadOnlySpan destinationNodeId, ReadOnlySpan requestNonce, ulong enrSequence) + { + Span authData = stackalloc byte[WhoAreYouAuthDataSize]; + Span idNonce = authData[..IdNonceSize]; + _cryptoRandom.GenerateRandomBytes(idNonce); + BinaryPrimitives.WriteUInt64BigEndian(authData[IdNonceSize..], enrSequence); + + return EncodePacket(destinationNodeId, PacketFlag.WhoAreYou, requestNonce, authData, default, null); + } + + [SkipLocalsInit] + internal byte[] EncodeHandshake(PublicKey destination, Challenge challenge, Discv5Message message, NodeRecord currentNodeRecord, out Session session) + { + using PrivateKey ephemeralKey = new PrivateKeyGenerator(_cryptoRandom).Generate(); + DeriveKeys( + destination, + ephemeralKey, + _localNodeId.Bytes, + destination.Hash.Bytes, + challenge.ChallengeData, + out byte[] initiatorKey, + out byte[] recipientKey); + + byte[] ephemeralPublicKey = ephemeralKey.CompressedPublicKey.Bytes; + byte[] record = challenge.EnrSequence < currentNodeRecord.EnrSequence + ? currentNodeRecord.ToRlpBytes() + : []; + + int authDataLength = HandshakeAuthDataHeadSize + IdSignatureSize + EphemeralPublicKeySize + record.Length; + byte[]? rentedAuthData = null; + Span authData = authDataLength <= MaxStackPacketBufferSize + ? stackalloc byte[authDataLength] + : (rentedAuthData = SafeArrayPool.Shared.Rent(authDataLength)).AsSpan(0, authDataLength); + + try + { + _localNodeId.Bytes.CopyTo(authData); + authData[NodeIdSize] = IdSignatureSize; + authData[NodeIdSize + 1] = EphemeralPublicKeySize; + SignIdNonce(challenge.ChallengeData, ephemeralPublicKey, destination.Hash.Bytes, authData.Slice(HandshakeAuthDataHeadSize, IdSignatureSize)); + ephemeralPublicKey.CopyTo(authData[(HandshakeAuthDataHeadSize + IdSignatureSize)..]); + record.CopyTo(authData[(HandshakeAuthDataHeadSize + IdSignatureSize + EphemeralPublicKeySize)..]); + + session = new Session(destination, recipientKey, initiatorKey); + Span nonce = stackalloc byte[NonceSize]; + _cryptoRandom.GenerateRandomBytes(nonce); + return EncodePacket(destination.Hash.Bytes, PacketFlag.Handshake, nonce, authData, initiatorKey, message); + } + finally + { + if (rentedAuthData is not null) + { + SafeArrayPool.Shared.Return(rentedAuthData); + } + } + } + + internal bool TryDecode(byte[] packet, out Packet decoded) + => TryDecode(packet.AsMemory(), out decoded); + + internal bool TryDecode(ReadOnlyMemory packet, out Packet decoded) + { + Aes localNodeMaskingAes = _decodeMaskingAesPool.Get(); + try + { + return TryDecode(packet, localNodeMaskingAes, out decoded); + } + finally + { + _decodeMaskingAesPool.Return(localNodeMaskingAes); + } + } + + internal static bool TryDecode(byte[] packet, ReadOnlySpan localNodeId, out Packet decoded) + => TryDecode(packet.AsMemory(), localNodeId, out decoded); + + internal static bool TryDecode(ReadOnlyMemory packetMemory, ReadOnlySpan localNodeId, out Packet decoded) + { + using Aes localNodeMaskingAes = CreateMaskingAes(localNodeId[..AesKeySize]); + return TryDecode(packetMemory, localNodeMaskingAes, out decoded); + } + + [SkipLocalsInit] + private static bool TryDecode(ReadOnlyMemory packetMemory, Aes localNodeMaskingAes, out Packet decoded) + { + decoded = default; + ReadOnlySpan packet = packetMemory.Span; + if (packet.Length is < MaskingIvSize + StaticHeaderSize or > MaxPacketSize) + { + return false; + } + + ReadOnlySpan maskingIv = packet[..MaskingIvSize]; + Span staticHeader = stackalloc byte[StaticHeaderSize]; + AesCtrTransform(localNodeMaskingAes, maskingIv, packet.Slice(MaskingIvSize, StaticHeaderSize), staticHeader); + ReadOnlySpan protocolId = ProtocolId; + if (!staticHeader[..protocolId.Length].SequenceEqual(protocolId)) + { + return false; + } + + int authDataSize = BinaryPrimitives.ReadUInt16BigEndian(staticHeader[(StaticHeaderSize - sizeof(ushort))..]); + int headerSize = StaticHeaderSize + authDataSize; + if (packet.Length < MaskingIvSize + headerSize) + { + return false; + } + + int messageAdLength = MaskingIvSize + headerSize; + byte[] messageAdBuffer = SafeArrayPool.Shared.Rent(messageAdLength); + Span messageAd = messageAdBuffer.AsSpan(0, messageAdLength); + maskingIv.CopyTo(messageAd); + Span header = messageAd[MaskingIvSize..]; + + try + { + AesCtrTransform(localNodeMaskingAes, maskingIv, packet.Slice(MaskingIvSize, headerSize), header); + if (!header[..protocolId.Length].SequenceEqual(protocolId)) + { + SafeArrayPool.Shared.Return(messageAdBuffer); + return false; + } + + int version = BinaryPrimitives.ReadUInt16BigEndian(header[protocolId.Length..]); + if (version != Version) + { + SafeArrayPool.Shared.Return(messageAdBuffer); + return false; + } + + int nonceOffset = MaskingIvSize + protocolId.Length + sizeof(ushort) + sizeof(byte); + int authDataOffset = MaskingIvSize + StaticHeaderSize; + PacketFlag flag = (PacketFlag)header[protocolId.Length + sizeof(ushort)]; + decoded = new Packet( + flag, + messageAdBuffer.AsMemory(nonceOffset, NonceSize), + messageAdBuffer.AsMemory(authDataOffset, authDataSize), + packetMemory.Slice(MaskingIvSize + headerSize), + messageAdBuffer, + messageAdLength); + return true; + } + catch + { + SafeArrayPool.Shared.Return(messageAdBuffer); + throw; + } + } + + internal bool TryDecryptMessage(scoped in Packet packet, ReadOnlySpan encryptionKey, out Discv5Message message) + => TryDecryptMessageForTest(in packet, encryptionKey, out message); + + internal static bool TryDecryptMessageForTest(scoped in Packet packet, ReadOnlySpan encryptionKey, out Discv5Message message) + { + message = null!; + ReadOnlySpan encryptedMessage = packet.Message.Span; + if (packet.Message.Length < AesGcmTagSize) + { + return false; + } + + ArrayPoolSpan plaintext = new(packet.Message.Length - AesGcmTagSize); + bool ownerTransferred = false; + try + { + using AesGcm aesGcm = new(encryptionKey, AesGcmTagSize); + aesGcm.Decrypt( + packet.Nonce.Span, + encryptedMessage[..plaintext.Length], + encryptedMessage.Slice(plaintext.Length, AesGcmTagSize), + plaintext, + packet.MessageAd.Span); + + ownerTransferred = true; + message = MessageCodec.DecodeOwned(plaintext.AsReadOnlyMemory(), plaintext); + return true; + } + catch (CryptographicException) + { + if (!ownerTransferred) + { + plaintext.Dispose(); + } + + return false; + } + catch + { + if (!ownerTransferred) + { + plaintext.Dispose(); + } + + throw; + } + } + + internal Challenge DecodeWhoAreYou(scoped in Packet packet) + { + if (packet.AuthData.Length != WhoAreYouAuthDataSize) + { + throw new RlpException("Invalid WHOAREYOU authdata length."); + } + + ulong enrSequence = BinaryPrimitives.ReadUInt64BigEndian(packet.AuthData.Span[IdNonceSize..]); + return new Challenge(enrSequence, packet.ChallengeData); + } + + internal bool TryDecryptHandshake( + scoped in Packet packet, + Challenge challenge, + NodeRecord? knownRecord, + out Session session, + out Discv5Message message, + out NodeRecord? nodeRecord) + { + session = null!; + message = null!; + nodeRecord = null; + + if (!TryReadHandshakeAuthData(packet.AuthData, out ValueHash256 sourceNodeId, out ReadOnlyMemory idSignature, out CompressedPublicKey? ephemeralPublicKey, out ReadOnlyMemory recordBytes)) + { + return false; + } + + if (recordBytes.Length > 0) + { + try + { + nodeRecord = NodeRecord.FromBytes(recordBytes.Span, _ecdsa); + } + catch (Exception) + { + return false; + } + } + + NodeRecord? record = nodeRecord ?? knownRecord; + CompressedPublicKey? remoteCompressedPublicKey = record?.GetObj(EnrContentKey.SecP256k1); + if (remoteCompressedPublicKey is null) + { + return false; + } + + PublicKey remotePublicKey = remoteCompressedPublicKey.Decompress(); + if (remotePublicKey.Hash != sourceNodeId) + { + return false; + } + + if (!VerifyIdSignature(remoteCompressedPublicKey, idSignature.Span, challenge.ChallengeData, ephemeralPublicKey.Bytes, _localNodeId.Bytes)) + { + return false; + } + + DeriveKeys(ephemeralPublicKey, sourceNodeId.Bytes, _localNodeId.Bytes, challenge.ChallengeData, out byte[] initiatorKey, out byte[] recipientKey); + + if (!TryDecryptMessage(in packet, initiatorKey, out message)) + { + return false; + } + + session = new Session(remotePublicKey, initiatorKey, recipientKey); + return true; + } + + internal static bool TryGetSourceNodeId(scoped in Packet packet, out ValueHash256 sourceNodeId) + { + sourceNodeId = default; + switch (packet.Flag) + { + case PacketFlag.Ordinary when packet.AuthData.Length == NodeIdSize: + sourceNodeId = new ValueHash256(packet.AuthData.Span); + return true; + case PacketFlag.Handshake when packet.AuthData.Length >= HandshakeAuthDataHeadSize: + sourceNodeId = new ValueHash256(packet.AuthData.Span[..NodeIdSize]); + return true; + default: + return false; + } + } + + private byte[] EncodePacket( + ReadOnlySpan destinationNodeId, + PacketFlag flag, + ReadOnlySpan nonce, + ReadOnlySpan authData, + ReadOnlySpan encryptionKey, + Discv5Message? message) + { + if (message is null) + { + return EncodePacketCore(destinationNodeId, flag, nonce, authData, default, default); + } + + using NettyRlpStream encodedMessage = MessageCodec.Encode(message); + return EncodePacketCore(destinationNodeId, flag, nonce, authData, encryptionKey, encodedMessage.AsSpan()); + } + + [SkipLocalsInit] + private byte[] EncodePacketCore( + ReadOnlySpan destinationNodeId, + PacketFlag flag, + ReadOnlySpan nonce, + ReadOnlySpan authData, + ReadOnlySpan encryptionKey, + ReadOnlySpan plaintext) + { + int headerLength = StaticHeaderSize + authData.Length; + int messageAdLength = MaskingIvSize + headerLength; + int encryptedMessageLength = plaintext.IsEmpty ? 0 : plaintext.Length + AesGcmTagSize; + + byte[] packet = new byte[MaskingIvSize + headerLength + encryptedMessageLength]; + Span maskingIv = packet.AsSpan(0, MaskingIvSize); + _cryptoRandom.GenerateRandomBytes(maskingIv); + + byte[]? rentedMessageAd = null; + Span messageAdBuffer = messageAdLength <= MaxStackPacketBufferSize + ? stackalloc byte[messageAdLength] + : (rentedMessageAd = SafeArrayPool.Shared.Rent(messageAdLength)).AsSpan(0, messageAdLength); + + try + { + maskingIv.CopyTo(messageAdBuffer); + WriteHeader(messageAdBuffer[MaskingIvSize..], flag, nonce, authData); + + if (!plaintext.IsEmpty) + { + EncryptMessage( + encryptionKey, + nonce, + plaintext, + messageAdBuffer, + packet.AsSpan(MaskingIvSize + headerLength, encryptedMessageLength)); + } + + AesCtrTransform(destinationNodeId[..AesKeySize], maskingIv, messageAdBuffer.Slice(MaskingIvSize, headerLength), packet.AsSpan(MaskingIvSize, headerLength)); + return packet; + } + finally + { + if (rentedMessageAd is not null) + { + SafeArrayPool.Shared.Return(rentedMessageAd); + } + } + } + + private static void WriteHeader(Span header, PacketFlag flag, ReadOnlySpan nonce, ReadOnlySpan authData) + { + if (nonce.Length != NonceSize) + { + throw new ArgumentException($"Nonce must be {NonceSize} bytes.", nameof(nonce)); + } + + ReadOnlySpan protocolId = ProtocolId; + protocolId.CopyTo(header); + BinaryPrimitives.WriteUInt16BigEndian(header[protocolId.Length..], Version); + header[protocolId.Length + sizeof(ushort)] = (byte)flag; + nonce.CopyTo(header.Slice(protocolId.Length + sizeof(ushort) + sizeof(byte), NonceSize)); + BinaryPrimitives.WriteUInt16BigEndian(header[(StaticHeaderSize - sizeof(ushort))..], checked((ushort)authData.Length)); + authData.CopyTo(header[StaticHeaderSize..]); + } + + private static void EncryptMessage( + ReadOnlySpan encryptionKey, + ReadOnlySpan nonce, + ReadOnlySpan plaintext, + ReadOnlySpan messageAd, + Span encrypted) + { + using AesGcm aesGcm = new(encryptionKey, AesGcmTagSize); + aesGcm.Encrypt( + nonce, + plaintext, + encrypted[..plaintext.Length], + encrypted.Slice(plaintext.Length, AesGcmTagSize), + messageAd); + } + + private static void AesCtrTransform(ReadOnlySpan key, ReadOnlySpan iv, ReadOnlySpan input, Span output) + { + using Aes aes = CreateMaskingAes(key); + AesCtrTransform(aes, iv, input, output); + } + + [SkipLocalsInit] + private static void AesCtrTransform(Aes aes, ReadOnlySpan iv, ReadOnlySpan input, Span output) + { + if (output.Length < input.Length) + { + throw new ArgumentException("Output span must be at least as long as input.", nameof(output)); + } + + Span counter = stackalloc byte[MaskingIvSize]; + iv.CopyTo(counter); + Span keyStream = stackalloc byte[MaskingIvSize]; + + int offset = 0; + while (offset < input.Length) + { + aes.EncryptEcb(counter, keyStream, PaddingMode.None); + + int blockLength = Math.Min(MaskingIvSize, input.Length - offset); + for (int i = 0; i < blockLength; i++) + { + output[offset + i] = (byte)(input[offset + i] ^ keyStream[i]); + } + + IncrementCounter(counter); + offset += blockLength; + } + } + + private static Aes CreateMaskingAes(ReadOnlySpan key) + { + Aes aes = Aes.Create(); + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.None; + aes.SetKey(key); + return aes; + } + + private static ObjectPool CreateDecodeMaskingAesPool(ReadOnlySpan key) + { + DefaultObjectPoolProvider provider = new() + { + MaximumRetained = Math.Min(Environment.ProcessorCount, MaxRetainedDecodeMaskingAes) + }; + + return provider.Create(new DecodeMaskingAesPolicy(key)); + } + + private sealed class DecodeMaskingAesPolicy : IPooledObjectPolicy + { + private readonly byte[] _key; + + public DecodeMaskingAesPolicy(ReadOnlySpan key) => _key = key.ToArray(); + + public Aes Create() => CreateMaskingAes(_key); + + public bool Return(Aes obj) => true; + } + + private static void IncrementCounter(Span counter) + { + for (int i = counter.Length - 1; i >= 0; i--) + { + counter[i]++; + if (counter[i] != 0) + { + return; + } + } + } + + private static void DeriveKeys( + PublicKey remotePublicKey, + PrivateKey ephemeralPrivateKey, + ReadOnlySpan initiatorNodeId, + ReadOnlySpan recipientNodeId, + ReadOnlySpan challengeData, + out byte[] initiatorKey, + out byte[] recipientKey) + { + byte[] sharedPoint = ephemeralPrivateKey.GetCompressedSharedPoint(remotePublicKey); + DeriveKeys(sharedPoint, initiatorNodeId, recipientNodeId, challengeData, out initiatorKey, out recipientKey); + } + + private void DeriveKeys( + CompressedPublicKey ephemeralPublicKey, + ReadOnlySpan initiatorNodeId, + ReadOnlySpan recipientNodeId, + ReadOnlySpan challengeData, + out byte[] initiatorKey, + out byte[] recipientKey) + { + byte[] sharedPoint = _privateKey.GetCompressedSharedPoint(ephemeralPublicKey); + DeriveKeys(sharedPoint, initiatorNodeId, recipientNodeId, challengeData, out initiatorKey, out recipientKey); + } + + [SkipLocalsInit] + private static void DeriveKeys( + byte[] secret, + ReadOnlySpan initiatorNodeId, + ReadOnlySpan recipientNodeId, + ReadOnlySpan challengeData, + out byte[] initiatorKey, + out byte[] recipientKey) + { + Span prk = stackalloc byte[SHA256.HashSizeInBytes]; + HMACSHA256.HashData(challengeData, secret, prk); + + ReadOnlySpan infoPrefix = KeyAgreementInfoPrefix; + Span info = stackalloc byte[infoPrefix.Length + NodeIdSize + NodeIdSize]; + infoPrefix.CopyTo(info); + initiatorNodeId.CopyTo(info[infoPrefix.Length..]); + recipientNodeId.CopyTo(info[(infoPrefix.Length + NodeIdSize)..]); + + Span hkdfInput = stackalloc byte[info.Length + 1]; + info.CopyTo(hkdfInput); + hkdfInput[^1] = 1; + + Span keyData = stackalloc byte[AesKeySize * 2]; + HMACSHA256.HashData(prk, hkdfInput, keyData); + initiatorKey = keyData[..AesKeySize].ToArray(); + recipientKey = keyData[AesKeySize..].ToArray(); + } + + internal static (byte[] InitiatorKey, byte[] RecipientKey) DeriveKeysForTest( + byte[] secret, + byte[] initiatorNodeId, + byte[] recipientNodeId, + byte[] challengeData) + { + DeriveKeys(secret, initiatorNodeId, recipientNodeId, challengeData, out byte[] initiatorKey, out byte[] recipientKey); + return (initiatorKey, recipientKey); + } + + [SkipLocalsInit] + private void SignIdNonce( + ReadOnlySpan challengeData, + ReadOnlySpan ephemeralPublicKey, + ReadOnlySpan recipientNodeId, + Span destination) + { + Span signingHash = stackalloc byte[SHA256.HashSizeInBytes]; + CalculateIdSignatureHash(challengeData, ephemeralPublicKey, recipientNodeId, signingHash); + Signature signature = _ecdsa.Sign(_privateKey, new ValueHash256(signingHash)); + signature.Bytes[..IdSignatureSize].CopyTo(destination); + } + + [SkipLocalsInit] + private bool VerifyIdSignature( + CompressedPublicKey signer, + ReadOnlySpan signatureBytes, + ReadOnlySpan challengeData, + ReadOnlySpan ephemeralPublicKey, + ReadOnlySpan recipientNodeId) + { + Span signingHash = stackalloc byte[SHA256.HashSizeInBytes]; + CalculateIdSignatureHash(challengeData, ephemeralPublicKey, recipientNodeId, signingHash); + for (int recoveryId = 0; recoveryId <= 1; recoveryId++) + { + Signature signature = new(signatureBytes, recoveryId); + CompressedPublicKey? recovered = _ecdsa.RecoverCompressedPublicKey(signature, new ValueHash256(signingHash)); + if (signer.Equals(recovered)) + { + return true; + } + } + + return false; + } + + internal static byte[] CalculateIdSignatureHashForTest(byte[] challengeData, byte[] ephemeralPublicKey, byte[] recipientNodeId) + { + byte[] signingHash = new byte[SHA256.HashSizeInBytes]; + CalculateIdSignatureHash(challengeData, ephemeralPublicKey, recipientNodeId, signingHash); + return signingHash; + } + + [SkipLocalsInit] + private static void CalculateIdSignatureHash( + ReadOnlySpan challengeData, + ReadOnlySpan ephemeralPublicKey, + ReadOnlySpan recipientNodeId, + Span destination) + { + ReadOnlySpan identityProofText = IdentityProofText; + int signingInputLength = identityProofText.Length + challengeData.Length + ephemeralPublicKey.Length + recipientNodeId.Length; + byte[]? rentedSigningInput = null; + Span signingInput = signingInputLength <= MaxStackPacketBufferSize + ? stackalloc byte[signingInputLength] + : (rentedSigningInput = SafeArrayPool.Shared.Rent(signingInputLength)).AsSpan(0, signingInputLength); + + try + { + identityProofText.CopyTo(signingInput); + challengeData.CopyTo(signingInput[identityProofText.Length..]); + ephemeralPublicKey.CopyTo(signingInput[(identityProofText.Length + challengeData.Length)..]); + recipientNodeId.CopyTo(signingInput[(identityProofText.Length + challengeData.Length + ephemeralPublicKey.Length)..]); + SHA256.HashData(signingInput, destination); + } + finally + { + if (rentedSigningInput is not null) + { + SafeArrayPool.Shared.Return(rentedSigningInput); + } + } + } + + private static bool TryReadHandshakeAuthData( + ReadOnlyMemory authDataMemory, + out ValueHash256 sourceNodeId, + out ReadOnlyMemory idSignature, + [NotNullWhen(true)] out CompressedPublicKey? ephemeralPublicKey, + out ReadOnlyMemory record) + { + sourceNodeId = default; + idSignature = ReadOnlyMemory.Empty; + ephemeralPublicKey = null; + record = ReadOnlyMemory.Empty; + + ReadOnlySpan authData = authDataMemory.Span; + if (authData.Length < HandshakeAuthDataHeadSize) + { + return false; + } + + sourceNodeId = new ValueHash256(authData[..NodeIdSize]); + int signatureSize = authData[NodeIdSize]; + int ephemeralKeySize = authData[NodeIdSize + 1]; + if (signatureSize != IdSignatureSize || ephemeralKeySize != EphemeralPublicKeySize) + { + return false; + } + + int signatureOffset = HandshakeAuthDataHeadSize; + int ephemeralKeyOffset = signatureOffset + signatureSize; + int recordOffset = ephemeralKeyOffset + ephemeralKeySize; + if (authData.Length < recordOffset) + { + return false; + } + + idSignature = authDataMemory.Slice(signatureOffset, signatureSize); + ephemeralPublicKey = new CompressedPublicKey(authData.Slice(ephemeralKeyOffset, ephemeralKeySize)); + record = authDataMemory[recordOffset..]; + return true; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketFlag.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketFlag.cs new file mode 100644 index 000000000000..d6b6b2dbcf5c --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/PacketFlag.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Network.Discovery.Discv5.Packets; + +internal enum PacketFlag : byte +{ + Ordinary = 0, + WhoAreYou = 1, + Handshake = 2 +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Session.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Session.cs new file mode 100644 index 000000000000..308326569696 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Packets/Session.cs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Buffers.Binary; +using System.Security.Cryptography; +using Nethermind.Core.Crypto; +using Nethermind.Crypto; + +namespace Nethermind.Network.Discovery.Discv5.Packets; + +internal sealed record Session(PublicKey RemotePublicKey, byte[] ReadKey, byte[] WriteKey) : IDisposable +{ + public const int KeySize = 16; + + private readonly Lock _lock = new(); + private long _nonceCounter; + private bool _disposed; + + /// + /// Writes the next nonce for an ordinary packet sent on this session. + /// + /// + /// Callers must first copy the write key with ; a false result means the session is + /// disposed and must not be used for another packet. + /// + public void WriteNextNonce(ICryptoRandom random, Span nonce) + { + if (nonce.Length != PacketCodec.NonceSize) + { + throw new ArgumentException($"Nonce must be {PacketCodec.NonceSize} bytes.", nameof(nonce)); + } + + BinaryPrimitives.WriteUInt32BigEndian(nonce, unchecked((uint)Interlocked.Increment(ref _nonceCounter))); + random.GenerateRandomBytes(nonce[sizeof(uint)..]); + } + + public bool TryCopyReadKey(Span destination) => TryCopyKey(ReadKey, destination); + + public bool TryCopyWriteKey(Span destination) => TryCopyKey(WriteKey, destination); + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + { + return; + } + + _disposed = true; + CryptographicOperations.ZeroMemory(ReadKey); + CryptographicOperations.ZeroMemory(WriteKey); + } + } + + private bool TryCopyKey(byte[] key, Span destination) + { + if (destination.Length != KeySize) + { + throw new ArgumentException($"Key destination must be {KeySize} bytes.", nameof(destination)); + } + + lock (_lock) + { + if (_disposed) + { + return false; + } + + key.CopyTo(destination); + return true; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/PooledUdpReceiveResult.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/PooledUdpReceiveResult.cs new file mode 100644 index 000000000000..8bfd6a51c55a --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/PooledUdpReceiveResult.cs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using Nethermind.Core.Collections; + +namespace Nethermind.Network.Discovery.Discv5; + +internal readonly struct PooledUdpReceiveResult(IPEndPoint remoteEndPoint, ArrayPoolSpan buffer) +{ + private readonly bool _hasBuffer = true; + private readonly ArrayPoolSpan _buffer = buffer; + + public ReadOnlyMemory Buffer => _buffer.AsReadOnlyMemory(); + + public IPEndPoint RemoteEndPoint { get; } = remoteEndPoint; + + internal void Dispose() + { + if (_hasBuffer) + { + _buffer.Dispose(); + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/FindNodeMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/FindNodeMsgSerializer.cs new file mode 100644 index 000000000000..93aa9032ae28 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/FindNodeMsgSerializer.cs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class FindNodeMsgSerializer() : MsgSerializerBase(MessageType.FindNode) +{ + protected override int GetContentLengthCore(FindNodeMsg msg) + => GetDistancesLength(msg.Distances); + + protected override void SerializeCore(NettyRlpStream stream, FindNodeMsg msg) + => EncodeDistances(stream, msg.Distances); + + protected override FindNodeMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + => new(requestId, DecodeDistances(ref ctx), owner); + + private static int GetDistancesLength(Distances distances) + { + int contentLength = 0; + for (int i = 0; i < distances.Count; i++) + { + contentLength += Rlp.LengthOf(distances[i]); + } + + return Rlp.LengthOfSequence(contentLength); + } + + private static void EncodeDistances(NettyRlpStream stream, Distances distances) + { + int contentLength = 0; + for (int i = 0; i < distances.Count; i++) + { + contentLength += Rlp.LengthOf(distances[i]); + } + + stream.StartSequence(contentLength); + for (int i = 0; i < distances.Count; i++) + { + Encode(stream, distances[i]); + } + } + + private static Distances DecodeDistances(ref Rlp.ValueDecoderContext ctx) + { + int checkPosition = ctx.ReadSequenceLength() + ctx.Position; + int count = ctx.PeekNumberOfItemsRemaining(checkPosition); + if (count > Distances.MaxCount) + { + throw new RlpException($"discv5 FINDNODE distance count {count} exceeds {Distances.MaxCount}."); + } + + Distances distances = new(count); + try + { + for (int i = 0; i < count; i++) + { + distances.Set(i, ctx.DecodePositiveInt()); + } + + ctx.Check(checkPosition); + return distances; + } + catch + { + distances.Dispose(); + throw; + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/MsgSerializerBase.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/MsgSerializerBase.cs new file mode 100644 index 000000000000..f3f83238ffea --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/MsgSerializerBase.cs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Runtime.CompilerServices; +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal interface IMsgSerializer +{ + MessageType MessageType { get; } + + bool RequiresOwnedMemory { get; } + + int GetContentLength(Discv5Message msg); + + void Serialize(NettyRlpStream stream, Discv5Message msg); + + Discv5Message Deserialize(ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner); +} + +internal abstract class MsgSerializerBase(MessageType messageType, bool requiresOwnedMemory = false) : IMsgSerializer + where TMessage : Discv5Message +{ + public MessageType MessageType { get; } = messageType; + + public bool RequiresOwnedMemory { get; } = requiresOwnedMemory; + + public int GetContentLength(TMessage msg) + => msg.RequestId.GetRlpLength() + GetContentLengthCore(msg); + + public void Serialize(NettyRlpStream stream, TMessage msg) + { + RequestId requestId = msg.RequestId; + EncodeRequestId(stream, in requestId); + SerializeCore(stream, msg); + } + + public TMessage Deserialize(ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + { + RequestId requestId = DecodeRequestId(ref ctx); + return DeserializeCore(in requestId, ref ctx, ownedMessage, owner); + } + + int IMsgSerializer.GetContentLength(Discv5Message msg) => GetContentLength((TMessage)msg); + + void IMsgSerializer.Serialize(NettyRlpStream stream, Discv5Message msg) => Serialize(stream, (TMessage)msg); + + Discv5Message IMsgSerializer.Deserialize(ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + => Deserialize(ref ctx, ownedMessage, owner); + + protected abstract int GetContentLengthCore(TMessage msg); + + protected abstract void SerializeCore(NettyRlpStream stream, TMessage msg); + + protected abstract TMessage DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner); + + protected static ReadOnlyMemory DecodeByteMemory(ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage) + { + ReadOnlySpan value = ctx.DecodeByteArraySpan(); + if (ownedMessage.IsEmpty) + { + throw new RlpException("discv5 byte fields require owned message memory."); + } + + return ownedMessage.Slice(1 + ctx.Position - value.Length, value.Length); + } + + protected static void Encode(NettyRlpStream stream, ulong value) => stream.Encode(value); + + protected static void Encode(NettyRlpStream stream, int value) => stream.Encode(value); + + private static RequestId DecodeRequestId(ref Rlp.ValueDecoderContext ctx) + { + ReadOnlySpan requestId = ctx.DecodeByteArraySpan(); + if (requestId.Length > RequestId.MaxLength) + { + throw new RlpException($"discv5 request-id length {requestId.Length} exceeds {RequestId.MaxLength}."); + } + + return RequestId.From(requestId); + } + + [SkipLocalsInit] + private static void EncodeRequestId(NettyRlpStream stream, in RequestId requestId) + { + Span bytes = stackalloc byte[RequestId.MaxLength]; + requestId.CopyTo(bytes); + stream.Encode(bytes[..requestId.Length]); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/NodesMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/NodesMsgSerializer.cs new file mode 100644 index 000000000000..ad1b9ff6cca5 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/NodesMsgSerializer.cs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics.CodeAnalysis; +using Nethermind.Core.Collections; +using Nethermind.Crypto; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Enr; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class NodesMsgSerializer() : MsgSerializerBase(MessageType.Nodes) +{ + private const int MaxNodeRecordsPerMessage = 16; + + private readonly IEcdsa _ecdsa = new Ecdsa(); + + protected override int GetContentLengthCore(NodesMsg msg) + => Rlp.LengthOf(msg.Total) + GetNodeRecordsLength(msg.Records); + + protected override void SerializeCore(NettyRlpStream stream, NodesMsg msg) + { + Encode(stream, msg.Total); + EncodeNodeRecords(stream, msg.Records); + } + + protected override NodesMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + { + int total = ctx.DecodePositiveInt(); + return new NodesMsg(requestId, total, DecodeNodeRecords(ref ctx), owner); + } + + private static int GetNodeRecordsLength(IReadOnlyList records) + { + int contentLength = 0; + for (int i = 0; i < records.Count; i++) + { + contentLength += records[i].GetRlpLengthWithSignature(); + } + + return Rlp.LengthOfSequence(contentLength); + } + + private static void EncodeNodeRecords(NettyRlpStream stream, IReadOnlyList records) + { + int contentLength = 0; + for (int i = 0; i < records.Count; i++) + { + contentLength += records[i].GetRlpLengthWithSignature(); + } + + stream.StartSequence(contentLength); + for (int i = 0; i < records.Count; i++) + { + records[i].Encode(stream); + } + } + + private NodeRecord[] DecodeNodeRecords(ref Rlp.ValueDecoderContext ctx) + { + int checkPosition = ctx.ReadSequenceLength() + ctx.Position; + int count = ctx.PeekNumberOfItemsRemaining(checkPosition); + if (count > MaxNodeRecordsPerMessage) + { + throw new RlpException($"discv5 NODES record count {count} exceeds {MaxNodeRecordsPerMessage}."); + } + + NodeRecord[] records = new NodeRecord[count]; + int recordCount = 0; + for (int i = 0; i < count; i++) + { + ReadOnlySpan record = ctx.PeekNextItem(); + ctx.SkipItem(); + if (TryDecodeNodeRecord(record, out NodeRecord? nodeRecord)) + { + records[recordCount++] = nodeRecord; + } + } + + ctx.Check(checkPosition); + if (recordCount != count) + { + Array.Resize(ref records, recordCount); + } + + return records; + } + + private bool TryDecodeNodeRecord(ReadOnlySpan record, [NotNullWhen(true)] out NodeRecord? nodeRecord) + { + try + { + nodeRecord = NodeRecord.FromBytes(record, _ecdsa); + return true; + } + catch (Exception e) when (IsMalformedNodeRecordException(e)) + { + nodeRecord = null; + return false; + } + } + + private static bool IsMalformedNodeRecordException(Exception exception) + => exception is RlpException or ArgumentException or InvalidOperationException or FormatException; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PingMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PingMsgSerializer.cs new file mode 100644 index 000000000000..1559cb78d3ae --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PingMsgSerializer.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class PingMsgSerializer() : MsgSerializerBase(MessageType.Ping) +{ + protected override int GetContentLengthCore(PingMsg msg) + => Rlp.LengthOf(msg.EnrSequence); + + protected override void SerializeCore(NettyRlpStream stream, PingMsg msg) + => Encode(stream, msg.EnrSequence); + + protected override PingMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + => new(requestId, ctx.DecodeULong(), owner); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PongMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PongMsgSerializer.cs new file mode 100644 index 000000000000..403eef5375b9 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/PongMsgSerializer.cs @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Network.Discovery.Serializers; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class PongMsgSerializer() : MsgSerializerBase(MessageType.Pong) +{ + private static readonly RlpLimit IpAddressRlpLimit = RlpLimit.For(16, nameof(PongMsg.RecipientIp)); + + protected override int GetContentLengthCore(PongMsg msg) + => Rlp.LengthOf(msg.EnrSequence) + + IPAddressRlp.GetLength(msg.RecipientIp) + + Rlp.LengthOf(msg.RecipientPort); + + protected override void SerializeCore(NettyRlpStream stream, PongMsg msg) + { + Encode(stream, msg.EnrSequence); + IPAddressRlp.Encode(stream, msg.RecipientIp); + Encode(stream, msg.RecipientPort); + } + + protected override PongMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + { + ulong enrSequence = ctx.DecodeULong(); + IPAddress recipientIp = new(ctx.DecodeByteArraySpan(IpAddressRlpLimit)); + int recipientPort = ctx.DecodePositiveInt(); + return new PongMsg(requestId, enrSequence, recipientIp, recipientPort, owner); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkReqMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkReqMsgSerializer.cs new file mode 100644 index 000000000000..90b7485efb0a --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkReqMsgSerializer.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class TalkReqMsgSerializer() : MsgSerializerBase(MessageType.TalkReq, requiresOwnedMemory: true) +{ + protected override int GetContentLengthCore(TalkReqMsg msg) + => Rlp.LengthOf(msg.Protocol) + Rlp.LengthOf(msg.Request); + + protected override void SerializeCore(NettyRlpStream stream, TalkReqMsg msg) + { + stream.Encode(msg.Protocol); + stream.Encode(msg.Request); + } + + protected override TalkReqMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + => new(requestId, DecodeByteMemory(ref ctx, ownedMessage), DecodeByteMemory(ref ctx, ownedMessage), owner); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkRespMsgSerializer.cs b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkRespMsgSerializer.cs new file mode 100644 index 000000000000..a33f8a9b292e --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/Serializers/TalkRespMsgSerializer.cs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Core.Collections; +using Nethermind.Network.Discovery.Discv5.Messages; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Discv5.Serializers; + +internal sealed class TalkRespMsgSerializer() : MsgSerializerBase(MessageType.TalkResp, requiresOwnedMemory: true) +{ + protected override int GetContentLengthCore(TalkRespMsg msg) + => Rlp.LengthOf(msg.Response); + + protected override void SerializeCore(NettyRlpStream stream, TalkRespMsg msg) + => stream.Encode(msg.Response); + + protected override TalkRespMsg DeserializeCore(in RequestId requestId, ref Rlp.ValueDecoderContext ctx, ReadOnlyMemory ownedMessage, ArrayPoolSpan? owner) + => new(requestId, DecodeByteMemory(ref ctx, ownedMessage), owner); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv5/discv5-bootnodes.json b/src/Nethermind/Nethermind.Network.Discovery/Discv5/discv5-bootnodes.json index 3612dfd4de8d..332e200b5ec4 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv5/discv5-bootnodes.json +++ b/src/Nethermind/Nethermind.Network.Discovery/Discv5/discv5-bootnodes.json @@ -1,6 +1,6 @@ [ - "enr:-KG4QMOEswP62yzDjSwWS4YEjtTZ5PO6r65CPqYBkgTTkrpaedQ8uEUo1uMALtJIvb2w_WWEVmg5yt1UAuK1ftxUU7QDhGV0aDKQu6TalgMAAAD__________4JpZIJ2NIJpcIQEnfA2iXNlY3AyNTZrMaEDfol8oLr6XJ7FsdAYE7lpJhKMls4G_v6qQOGKJUWGb_uDdGNwgiMog3VkcIIjKA", - "enr:-KG4QF4B5WrlFcRhUU6dZETwY5ZzAXnA0vGC__L1Kdw602nDZwXSTs5RFXFIFUnbQJmhNGVU6OIX7KVrCSTODsz1tK4DhGV0aDKQu6TalgMAAAD__________4JpZIJ2NIJpcIQExNYEiXNlY3AyNTZrMaECQmM9vp7KhaXhI-nqL_R0ovULLCFSFTa9CPPSdb1zPX6DdGNwgiMog3VkcIIjKA", + "enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo", + "enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo", "enr:-Ku4QImhMc1z8yCiNJ1TyUxdcfNucje3BGwEHzodEZUan8PherEo4sF7pPHPSIB1NNuSg5fZy7qFsjmUKs2ea1Whi0EBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQOVphkDqal4QzPMksc5wnpuC3gvSC8AfbFOnZY_On34wIN1ZHCCIyg", "enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA", "enr:-Ku4QPp9z1W4tAO8Ber_NQierYaOStqhDqQdOPY3bB3jDgkjcbk6YrEnVYIiCBbTxuar3CzS528d2iE7TdJsrL-dEKoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMw5fqqkw2hHC4F5HZZDPsNmPdB1Gi8JPQK7pRc9XHh-oN1ZHCCKvg", @@ -13,5 +13,7 @@ "enr:-Ku4QPn5eVhcoF1opaFEvg1b6JNFD2rqVkHQ8HApOKK61OIcIXD127bKWgAtbwI7pnxx6cDyk_nI88TrZKQaGMZj0q0Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhDayLMaJc2VjcDI1NmsxoQK2sBOLGcUb4AwuYzFuAVCaNHA-dy24UuEKkeFNgCVCsIN1ZHCCIyg", "enr:-Ku4QEWzdnVtXc2Q0ZVigfCGggOVB2Vc1ZCPEc6j21NIFLODSJbvNaef1g4PxhPwl_3kax86YPheFUSLXPRs98vvYsoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhDZBrP2Jc2VjcDI1NmsxoQM6jr8Rb1ktLEsVcKAPa08wCsKUmvoQ8khiOl_SLozf9IN1ZHCCIyg", "enr:-LK4QA8FfhaAjlb_BXsXxSfiysR7R52Nhi9JBt4F8SPssu8hdE1BXQQEtVDC3qStCW60LSO7hEsVHv5zm8_6Vnjhcn0Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhAN4aBKJc2VjcDI1NmsxoQJerDhsJ-KxZ8sHySMOCmTO6sHM3iCFQ6VMvLTe948MyYN0Y3CCI4yDdWRwgiOM", - "enr:-LK4QKWrXTpV9T78hNG6s8AM6IO4XH9kFT91uZtFg1GcsJ6dKovDOr1jtAAFPnS2lvNltkOGA9k29BUN7lFh_sjuc9QBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhANAdd-Jc2VjcDI1NmsxoQLQa6ai7y9PMN5hpLe5HmiJSlYzMuzP7ZhwRiwHvqNXdoN0Y3CCI4yDdWRwgiOM" + "enr:-LK4QKWrXTpV9T78hNG6s8AM6IO4XH9kFT91uZtFg1GcsJ6dKovDOr1jtAAFPnS2lvNltkOGA9k29BUN7lFh_sjuc9QBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhANAdd-Jc2VjcDI1NmsxoQLQa6ai7y9PMN5hpLe5HmiJSlYzMuzP7ZhwRiwHvqNXdoN0Y3CCI4yDdWRwgiOM", + "enr:-IS4QPi-onjNsT5xAIAenhCGTDl4z-4UOR25Uq-3TmG4V3kwB9ljLTb_Kp1wdjHNj-H8VVLRBSSWVZo3GUe3z6k0E-IBgmlkgnY0gmlwhKB3_qGJc2VjcDI1NmsxoQMvAfgB4cJXvvXeM6WbCG86CstbSxbQBSGx31FAwVtOTYN1ZHCCIyg", + "enr:-KG4QPUf8-g_jU-KrwzG42AGt0wWM1BTnQxgZXlvCEIfTQ5hSmptkmgmMbRkpOqv6kzb33SlhPHJp7x4rLWWiVq5lSECgmlkgnY0gmlwhFPlR9KDaXA2kCoGxcAJAAAVAAAAAAAAABCJc2VjcDI1NmsxoQLdUv9Eo9sxCt0tc_CheLOWnX59yHJtkBSOL7kpxdJ6GYN1ZHCCIyiEdWRwNoIjKA" ] diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaConfigFactory.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaConfigFactory.cs new file mode 100644 index 000000000000..28ebdf9990cb --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaConfigFactory.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Kademlia; + +internal static class DiscoveryKademliaConfigFactory +{ + public static KademliaConfig Create(Node currentNode, IReadOnlyList bootNodes, IDiscoveryConfig discoveryConfig) + => new() + { + CurrentNodeId = currentNode, + KSize = discoveryConfig.BucketSize, + Alpha = discoveryConfig.Concurrency, + Beta = discoveryConfig.BitsPerHop, + LookupFindNeighbourHardTimeout = TimeSpan.FromMilliseconds(discoveryConfig.PingTimeout + discoveryConfig.BondWaitTime + (2L * discoveryConfig.SendNodeTimeout)), + RefreshPingTimeout = TimeSpan.FromMilliseconds(discoveryConfig.PingTimeout), + RefreshInterval = TimeSpan.FromMilliseconds(discoveryConfig.DiscoveryInterval), + BootNodes = bootNodes + }; +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaModuleBase.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaModuleBase.cs new file mode 100644 index 000000000000..a76e69d06205 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryKademliaModuleBase.cs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Autofac; +using Autofac.Core; +using Autofac.Features.AttributeFilters; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery.Kademlia; + +public abstract class DiscoveryKademliaModuleBase(Node currentNode, IReadOnlyList bootNodes, string discoveryStorageKey) : Module +{ + protected override void Load(ContainerBuilder builder) + { + RegisterProtocolServices(builder); + + builder + .AddModule(new KademliaModule()) + .AddSingleton>(Hash256KademliaDistance.Instance) + .AddSingleton, PublicKeyKeyOperator>() + .AddSingleton, IDiscoveryConfig>((discoveryConfig) => DiscoveryKademliaConfigFactory.Create(currentNode, bootNodes, discoveryConfig)); + + builder.RegisterType() + .AsSelf() + .WithAttributeFiltering() + .WithParameter(ResolvedParameter.ForKeyed(discoveryStorageKey)) + .SingleInstance(); + } + + protected abstract void RegisterProtocolServices(ContainerBuilder builder); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryPersistenceManager.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryPersistenceManager.cs similarity index 76% rename from src/Nethermind/Nethermind.Network.Discovery/DiscoveryPersistenceManager.cs rename to src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryPersistenceManager.cs index 595495e263a7..e51aa4d2ab72 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/DiscoveryPersistenceManager.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DiscoveryPersistenceManager.cs @@ -1,17 +1,14 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using Autofac.Features.AttributeFilters; using Nethermind.Config; using Nethermind.Core.Crypto; -using Nethermind.Db; +using Nethermind.Kademlia; using Nethermind.Logging; -using Nethermind.Network.Discovery.Discv4; -using Nethermind.Network.Discovery.Kademlia; using Nethermind.Stats; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery; +namespace Nethermind.Network.Discovery.Kademlia; /// /// Manages persistence operations for the discovery process, including loading nodes from storage @@ -22,21 +19,22 @@ namespace Nethermind.Network.Discovery; /// /// The network storage for persisting discovery nodes. /// Manager for node statistics. -/// Adapter for Discv4 protocol communication. +/// Protocol-specific Kademlia message sender. +/// Kademlia table whose live nodes should be persisted. /// Configuration for the discovery process. /// Log manager for logging events. /// Thrown if any required parameter is null. -public class DiscoveryPersistenceManager( - [KeyFilter(DbNames.DiscoveryNodes)] INetworkStorage discoveryStorage, +public sealed class DiscoveryPersistenceManager( + INetworkStorage discoveryStorage, INodeStatsManager nodeStatsManager, - IKademliaDiscv4Adapter discv4Adapter, + IKademliaMessageSender messageSender, IKademlia kademlia, IDiscoveryConfig discoveryConfig, ILogManager logManager) { private readonly INetworkStorage _discoveryStorage = discoveryStorage; private readonly INodeStatsManager _nodeStatsManager = nodeStatsManager; - private readonly IKademliaDiscv4Adapter _discv4Adapter = discv4Adapter; + private readonly IKademliaMessageSender _messageSender = messageSender; private readonly IKademlia _kademlia = kademlia; private readonly ILogger _logger = logManager.GetClassLogger(); private readonly int _persistenceInterval = discoveryConfig.DiscoveryPersistenceInterval; @@ -56,11 +54,11 @@ public async Task LoadPersistedNodes(CancellationToken cancellationToken) Node node; try { - node = new Node(networkNode.NodeId, networkNode.Host, networkNode.Port); + node = new Node(networkNode); } catch (Exception e) { - _logger.DebugError($"Peer could not be loaded for {networkNode.NodeId}@{networkNode.Host}:{networkNode.Port}. {e}"); + _logger.DebugError($"Peer could not be loaded for persisted node {networkNode}. {e}"); continue; } @@ -69,7 +67,7 @@ public async Task LoadPersistedNodes(CancellationToken cancellationToken) { // Reputation must be set before Ping so the routing table has the correct reputation when the Pong is received. _nodeStatsManager.GetOrAdd(node).CurrentPersistedNodeReputation = networkNode.Reputation; - if (!await _discv4Adapter.Ping(node, cancellationToken)) + if (!await _messageSender.Ping(node, cancellationToken)) { continue; } @@ -107,12 +105,15 @@ public async Task RunDiscoveryPersistenceCommit(CancellationToken cancellationTo { try { - _discoveryStorage.StartBatch(); - - _discoveryStorage.UpdateNodes(_kademlia - .IterateNodes() - .Select(x => new NetworkNode(x.Id, x.Host, x.Port, _nodeStatsManager.GetNewPersistedReputation(x)))); + List nodes = []; + foreach (Node node in _kademlia.IterateNodes()) + { + long reputation = _nodeStatsManager.GetNewPersistedReputation(node); + nodes.Add(CreatePersistedNode(node, reputation)); + } + _discoveryStorage.StartBatch(); + _discoveryStorage.UpdateNodes(nodes); _discoveryStorage.Commit(); } catch (Exception ex) @@ -121,4 +122,14 @@ public async Task RunDiscoveryPersistenceCommit(CancellationToken cancellationTo } } } + + private static NetworkNode CreatePersistedNode(Node node, long reputation) + { + if (!string.IsNullOrEmpty(node.Enr)) + { + return new NetworkNode(node.Enr) { Reputation = reputation }; + } + + return new NetworkNode(node.Id, node.Host, node.Port, reputation); + } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DoubleEndedLru.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DoubleEndedLru.cs deleted file mode 100644 index 27beaeaa3e0e..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/DoubleEndedLru.cs +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; -using Nethermind.Core.Threading; -using NonBlocking; - -namespace Nethermind.Network.Discovery.Kademlia; - -public class DoubleEndedLru(int capacity) where TNode : notnull -{ - private readonly McsLock _lock = new(); - - private readonly LinkedList<(ValueHash256, TNode)> _queue = new(); - private readonly ConcurrentDictionary> _hashMapping = new(); - public int Count => _queue.Count; - - public BucketAddResult AddOrRefresh(in ValueHash256 hash, TNode node) - { - using McsLock.Disposable _ = _lock.Acquire(); - - if (_hashMapping.TryGetValue(hash, out LinkedListNode<(ValueHash256, TNode)>? listNode)) - { - _queue.Remove(listNode); - _queue.AddFirst(listNode); - return BucketAddResult.Refreshed; - } - - if (_queue.Count >= capacity) - { - return BucketAddResult.Full; - } - - listNode = _queue.AddFirst((hash, node)); - _hashMapping.TryAdd(hash, listNode); - return BucketAddResult.Added; - } - - public bool TryPopHead(out ValueHash256 hash, out TNode? node) - { - using McsLock.Disposable _ = _lock.Acquire(); - - LinkedListNode<(ValueHash256, TNode)>? front = _queue.First; - if (front == null) - { - hash = default; - node = default; - return false; - } - - _queue.Remove(front); - hash = front.Value.Item1; - node = front.Value.Item2; - _hashMapping.TryRemove(front.Value.Item1, out front); - - return true; - } - - public bool TryGetLast(out TNode? last) - { - using McsLock.Disposable _ = _lock.Acquire(); - - LinkedListNode<(ValueHash256, TNode)>? lastNode = _queue.Last; - if (lastNode == null) - { - last = default; - return false; - } - - last = lastNode.Value.Item2; - return true; - } - - public bool Remove(ValueHash256 hash) - { - using McsLock.Disposable _ = _lock.Acquire(); - - if (_hashMapping.TryRemove(hash, out LinkedListNode<(ValueHash256, TNode)>? listNode)) - { - _queue.Remove(listNode); - return true; - } - - return false; - } - - public TNode[] GetAll() - { - using McsLock.Disposable _ = _lock.Acquire(); - TNode[] result = new TNode[_queue.Count]; - int i = 0; - foreach ((ValueHash256, TNode node) entry in _queue) result[i++] = entry.node; - return result; - } - - public (ValueHash256, TNode)[] GetAllWithHash() - { - using McsLock.Disposable _ = _lock.Acquire(); - (ValueHash256, TNode)[] result = new (ValueHash256, TNode)[_queue.Count]; - int i = 0; - foreach ((ValueHash256, TNode) entry in _queue) result[i++] = entry; - return result; - } - - public bool Contains(in ValueHash256 hash) => _hashMapping.ContainsKey(hash); - - public TNode? GetByHash(ValueHash256 hash) => - _hashMapping.TryGetValue(hash, out LinkedListNode<(ValueHash256, TNode)>? listNode) ? listNode.Value.Item2 : default; -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/FromKeyNodeHashProvider.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/FromKeyNodeHashProvider.cs deleted file mode 100644 index ff9c01de1330..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/FromKeyNodeHashProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -public class FromKeyNodeHashProvider(IKeyOperator keyOperator) : INodeHashProvider -{ - public ValueHash256 GetHash(TNode node) => keyOperator.GetNodeHash(node); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256KademliaDistance.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256KademliaDistance.cs new file mode 100644 index 000000000000..a501696c7f19 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256KademliaDistance.cs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Numerics; +using System.Runtime.CompilerServices; +using Nethermind.Core.Crypto; +using Nethermind.Kademlia; + +namespace Nethermind.Network.Discovery.Kademlia; + +/// +/// Kademlia XOR-distance operations for Nethermind's 256-bit hash type. +/// +public sealed class Hash256KademliaDistance : IKademliaDistance +{ + /// + /// Shared stateless instance. + /// + public static Hash256KademliaDistance Instance { get; } = new(); + + /// + public int MaxDistance => Hash256.Size * 8; + + /// + public Hash256 Zero => Hash256.Zero; + + /// + [SkipLocalsInit] + public int CalculateLogDistance(Hash256 left, Hash256 right) + { + Span xorDistance = stackalloc byte[Hash256.Size]; + XorDistance(left.Bytes, right.Bytes, xorDistance); + int zeros = 0; + + for (int i = 0; i < Hash256.Size; i++) + { + byte xor = xorDistance[i]; + if (xor == 0) + { + zeros += 8; + continue; + } + + int nonZeroPostfix = 1; + while ((xor >>= 1) != 0) + { + nonZeroPostfix++; + } + + zeros += 8 - nonZeroPostfix; + break; + } + + return MaxDistance - zeros; + } + + /// + [SkipLocalsInit] + public int Compare(Hash256 left, Hash256 right, Hash256 target) + { + Span leftDistance = stackalloc byte[Hash256.Size]; + Span rightDistance = stackalloc byte[Hash256.Size]; + ReadOnlySpan targetBytes = target.Bytes; + XorDistance(left.Bytes, targetBytes, leftDistance); + XorDistance(right.Bytes, targetBytes, rightDistance); + + return leftDistance.SequenceCompareTo(rightDistance); + } + + /// + public bool GetBit(Hash256 key, int index) + { + int byteIndex = index / 8; + int bitIndex = index % 8; + return (key.Bytes[byteIndex] & (1 << (7 - bitIndex))) != 0; + } + + /// + [SkipLocalsInit] + public Hash256 SetBit(Hash256 key, int index) + { + Span bytes = stackalloc byte[Hash256.Size]; + key.Bytes.CopyTo(bytes); + bytes[index / 8] |= (byte)(1 << (7 - (index % 8))); + return new Hash256(bytes); + } + + /// + /// Creates a random 256-bit key at the requested XOR log distance from . + /// + public Hash256 GetRandomHashAtDistance(Hash256 currentHash, int distance) => + GetRandomHashAtDistance(currentHash, distance, Random.Shared); + + /// + /// Creates a random 256-bit key at the requested XOR log distance from . + /// + [SkipLocalsInit] + public Hash256 GetRandomHashAtDistance(Hash256 currentHash, int distance, Random random) + { + if ((uint)distance > MaxDistance) + { + throw new ArgumentOutOfRangeException(nameof(distance), distance, $"Distance must be between 0 and {MaxDistance}."); + } + + Span randomized = stackalloc byte[Hash256.Size]; + random.NextBytes(randomized); + return CopyForRandom(currentHash, randomized, MaxDistance - distance); + } + + private Hash256 CopyForRandom(Hash256 currentHash, Span randomizedHash, int distance) + { + if (distance >= MaxDistance) + { + return currentHash; + } + + currentHash.Bytes[..(distance / 8)].CopyTo(randomizedHash); + + int remainingBit = distance % 8; + int remainingBitByte = distance / 8; + byte mask = (byte)(~((1 << (8 - remainingBit)) - 1)); + byte randomized = randomizedHash[remainingBitByte]; + byte original = currentHash.Bytes[remainingBitByte]; + randomizedHash[remainingBitByte] = (byte)((original & mask) | (randomized & ~mask)); + + if (distance <= MaxDistance - 1) + { + int nextBit = distance % 8; + int nextBitByte = distance / 8; + mask = (byte)(1 << (7 - nextBit)); + randomized = randomizedHash[nextBitByte]; + byte opposite = (byte)~currentHash.Bytes[nextBitByte]; + randomizedHash[nextBitByte] = (byte)((opposite & mask) | (randomized & ~mask)); + } + + return new Hash256(randomizedHash); + } + + private static void XorDistance(ReadOnlySpan left, ReadOnlySpan right, Span destination) + { + int i = 0; + for (; i <= destination.Length - Vector.Count; i += Vector.Count) + { + (new Vector(left[i..]) ^ new Vector(right[i..])).CopyTo(destination[i..]); + } + + for (; i < destination.Length; i++) + { + destination[i] = (byte)(left[i] ^ right[i]); + } + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256XorUtils.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256XorUtils.cs deleted file mode 100644 index 4f042e06435b..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Hash256XorUtils.cs +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Numerics; -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -public static class Hash256XorUtils -{ - public static int CalculateLogDistance(ValueHash256 h1, ValueHash256 h2) - { - ValueHash256 xor = XorDistance(h1, h2); - int zeros = 0; - for (int i = 0; i < 32; i += 1) - { - byte xord = xor.Bytes[i]; - if (xord == 0) - { - zeros += 8; - continue; - } - - int nonZeroPostfix = 1; - while ((xord >>= 1) != 0) - { - nonZeroPostfix++; - } - zeros += 8 - nonZeroPostfix; - - break; - } - return MaxDistance - zeros; - } - - public const int MaxDistance = 256; - - public static int Compare(ValueHash256 a, ValueHash256 b, ValueHash256 c) - { - ValueHash256 ac = XorDistance(a, c); - ValueHash256 bc = XorDistance(b, c); - return ac.CompareTo(bc); - } - - public static ValueHash256 XorDistance(ValueHash256 hash1, ValueHash256 hash2) - { - ValueHash256 bc = new(); - ReadOnlySpan hash1Bytes = hash1.BytesAsSpan; - ReadOnlySpan hash2Bytes = hash2.BytesAsSpan; - Span result = bc.BytesAsSpan; - - int i = 0; - for (; i <= result.Length - Vector.Count; i += Vector.Count) - { - (new Vector(hash1Bytes[i..]) ^ new Vector(hash2Bytes[i..])).CopyTo(result[i..]); - } - - for (; i < result.Length; i++) - { - result[i] = (byte)(hash1Bytes[i] ^ hash2Bytes[i]); - } - - return bc; - } - - public static ValueHash256 GetRandomHashAtDistance(ValueHash256 currentHash, int distance) => GetRandomHashAtDistance(currentHash, distance, Random.Shared); - - public static ValueHash256 GetRandomHashAtDistance(ValueHash256 currentHash, int distance, Random random) - { - // TODO: Just add a min/max range per bucket and randomized between them. - if (distance == MaxDistance) - { - return currentHash; - } - - ValueHash256 randomized = new(); - random.NextBytes(randomized.BytesAsSpan); - return CopyForRandom(currentHash, randomized, MaxDistance - distance); - } - - private static ValueHash256 CopyForRandom(ValueHash256 currentHash, ValueHash256 randomizedHash, int distance) - { - if (distance >= 256) return currentHash; - - currentHash.Bytes[0..(distance / 8)].CopyTo(randomizedHash.BytesAsSpan); - - int remainingBit = distance % 8; - int remainingBitByte = distance / 8; - byte mask = (byte)(~((1 << (8 - remainingBit)) - 1)); - byte randomized = randomizedHash.BytesAsSpan[remainingBitByte]; - byte original = currentHash.BytesAsSpan[remainingBitByte]; - randomizedHash.BytesAsSpan[remainingBitByte] = (byte)((original & mask) | (randomized & (~mask))); - - if (distance <= 255) - { - // So it always assume that the next bucket (the closer one) is always populated and therefore, - // the bits here for that distance must not be the same as in currentHash. - int nextBit = distance % 8; - int nextBitByte = distance / 8; - mask = (byte)(1 << (7 - nextBit)); - randomized = randomizedHash.BytesAsSpan[nextBitByte]; - byte opposite = (byte)~(currentHash.BytesAsSpan[nextBitByte]); - - byte final = (byte)((opposite & mask) | (randomized & ~(mask))); - randomizedHash.BytesAsSpan[nextBitByte] = final; - } - - return randomizedHash; - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IIteratorNodeLookup.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IIteratorNodeLookup.cs deleted file mode 100644 index c23bfe490615..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IIteratorNodeLookup.cs +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -namespace Nethermind.Network.Discovery.Kademlia; - -public interface IIteratorNodeLookup -{ - IAsyncEnumerable Lookup(TKey target, CancellationToken token); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademlia.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademlia.cs deleted file mode 100644 index 1c6c090d52f9..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademlia.cs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// Main kademlia interface. High level code is expected to interface with this interface. -/// -/// -/// -public interface IKademlia -{ - /// - /// Add node to the table. - /// - /// - void AddOrRefresh(TNode node); - - /// - /// Remove from to the table. - /// - /// - void Remove(TNode node); - - /// - /// Start timers, refresh and such for maintenance of the table. - /// - /// - Task Run(CancellationToken token); - - /// - /// Just do the bootstrap sequence, which is to initiate a lookup on current node id. - /// Also do a refresh on all bucket which is not part of joining strictly speaking. - /// - /// - Task Bootstrap(CancellationToken token); - - /// - /// Lookup k nearest neighbour closest to the target hash. This will traverse the network. - /// - /// - /// - /// - Task LookupNodesClosest(TKey key, CancellationToken token, int? k = null); - - /// - /// Return the K nearest table entry from target. This does not traverse the network. The returned array is not - /// sorted. The routing table may return the exact same array for optimization purpose. - /// - /// - /// - /// - TNode[] GetKNeighbour(TKey target, TNode? excluding = default, bool excludeSelf = false); - - /// - /// Called when a TNode is added to the routing table. - /// - event EventHandler OnNodeAdded; - - /// - /// Called when a TNode is removed from the routing table. - /// - event EventHandler OnNodeRemoved; - - /// - /// Iterate all nodes with no ordering - /// - /// - IEnumerable IterateNodes(); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaMessageSender.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaMessageSender.cs deleted file mode 100644 index 47a4305c1570..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaMessageSender.cs +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// Should be exposed by application to kademlia so that kademlia can send out message. -/// -/// -/// -public interface IKademliaMessageSender -{ - Task Ping(TNode receiver, CancellationToken token); - Task FindNeighbours(TNode receiver, TKey target, CancellationToken token); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaNodeSource.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaNodeSource.cs similarity index 92% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaNodeSource.cs rename to src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaNodeSource.cs index 0ee9c86d7102..9925fc8d113b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/IKademliaNodeSource.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKademliaNodeSource.cs @@ -3,7 +3,7 @@ using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Kademlia; /// /// Interface for discovering nodes in a Kademlia distributed hash table network. diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKeyOperator.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKeyOperator.cs deleted file mode 100644 index b6a0a9edf3b7..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IKeyOperator.cs +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// Define operations for and . -/// -/// -/// -public interface IKeyOperator -{ - TKey GetKey(TNode node); - ValueHash256 GetKeyHash(TKey key); - ValueHash256 GetNodeHash(TNode node) => GetKeyHash(GetKey(node)); - TKey CreateRandomKeyAtDistance(ValueHash256 nodePrefix, int depth); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHashProvider.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHashProvider.cs deleted file mode 100644 index c3cda5c61506..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/INodeHashProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// Just a convenient interface with only one generic parameter. -/// -/// -public interface INodeHashProvider -{ - ValueHash256 GetHash(TNode node); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IRoutingTable.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IRoutingTable.cs deleted file mode 100644 index 52ff09bc0a31..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IRoutingTable.cs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -public interface IRoutingTable where TNode : notnull -{ - BucketAddResult TryAddOrRefresh(in ValueHash256 hash, TNode item, out TNode? toRefresh); - bool Remove(in ValueHash256 hash); - TNode[] GetKNearestNeighbour(ValueHash256 hash, ValueHash256? exclude = null, bool excludeSelf = false); - TNode[] GetAllAtDistance(int i); - IEnumerable<(ValueHash256 Prefix, int Distance, KBucket Bucket)> IterateBuckets(); - TNode? GetByHash(ValueHash256 nodeId); - void LogDebugInfo(); - event EventHandler? OnNodeAdded; - event EventHandler? OnNodeRemoved; - int Size { get; } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IteratorNodeLookup.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IteratorNodeLookup.cs deleted file mode 100644 index a3764820d608..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/IteratorNodeLookup.cs +++ /dev/null @@ -1,210 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Runtime.CompilerServices; -using Nethermind.Core; -using Nethermind.Core.Caching; -using Nethermind.Core.Crypto; -using Nethermind.Core.Extensions; -using Nethermind.Core.Utils; -using Nethermind.Logging; -using NonBlocking; - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// Special lookup made specially for node discovery as the standard lookup is too slow or unnecessarily parallelized. -/// Instead of returning k closest node, it just returns the nodes that it found along the way and stopped early. -/// This is useful for node discovery as trying to get the k closest node is not completely necessary, as the main goal -/// is to reach all node. The lookup is not parallelized as it is expected to be parallelized at a higher level with -/// each worker having different target to look into. -/// -public class IteratorNodeLookup( - IRoutingTable routingTable, - KademliaConfig kademliaConfig, - IKademliaMessageSender msgSender, - IKeyOperator keyOperator, - ITimestamper timestamper, - ILogManager logManager) : IIteratorNodeLookup where TNode : notnull -{ - private readonly ILogger _logger = logManager.GetClassLogger>(); - private readonly ValueHash256 _currentNodeIdAsHash = keyOperator.GetNodeHash(kademliaConfig.CurrentNodeId); - - // Small lru of unreachable nodes, prevent retrying. Pretty effective, although does not improve discovery overall. - private readonly LruCache _unreachableNodes = new(256, ""); - - // The maximum round per lookup. Higher means that it will 'see' deeper into the network, but come at a latency - // cost of trying many node for increasingly lower new node. - private const int MaxRounds = 3; - - // These two dont come into effect as MaxRounds is low. - private const int MaxNonProgressingRound = 3; - private const int MinResult = 128; - - private bool SameAsSelf(TNode node) => keyOperator.GetNodeHash(node) == _currentNodeIdAsHash; - - public async IAsyncEnumerable Lookup(TKey target, [EnumeratorCancellation] CancellationToken token) - { - ValueHash256 targetHash = keyOperator.GetKeyHash(target); - if (_logger.IsDebug) _logger.Debug($"Initiate lookup for hash {targetHash}"); - - using AutoCancelTokenSource cts = token.CreateChildTokenSource(); - token = cts.Token; - - ConcurrentDictionary queried = new(); - ConcurrentDictionary seen = new(); - - IComparer comparer = Comparer.Create((h1, h2) => - Hash256XorUtils.Compare(h1, h2, targetHash)); - - // Ordered by lowest distance. Will get popped for next round. - PriorityQueue<(ValueHash256, TNode), ValueHash256> queryQueue = new(comparer); - - // Used to determine if the worker should stop - ValueHash256 bestNodeId = ValueKeccak.Zero; - int closestNodeRound = 0; - int currentRound = 0; - int totalResult = 0; - - // Check internal table first - foreach (TNode node in routingTable.GetKNearestNeighbour(targetHash, null)) - { - ValueHash256 nodeHash = keyOperator.GetNodeHash(node); - seen.TryAdd(nodeHash, node); - - queryQueue.Enqueue((nodeHash, node), nodeHash); - - yield return node; - - if (bestNodeId == ValueKeccak.Zero || comparer.Compare(nodeHash, bestNodeId) < 0) - { - bestNodeId = nodeHash; - } - } - - while (true) - { - token.ThrowIfCancellationRequested(); - if (!queryQueue.TryDequeue(out (ValueHash256 hash, TNode node) toQuery, out ValueHash256 hash256)) - { - // No node to query and running query. - if (_logger.IsTrace) _logger.Trace("Stopping lookup. No node to query."); - yield break; - } - - if (SameAsSelf(toQuery.node)) continue; - - queried.TryAdd(toQuery.hash, toQuery.node); - if (_logger.IsTrace) _logger.Trace($"Query {toQuery.node} at round {currentRound}"); - - TNode[]? neighbours = await FindNeighbour(toQuery.node, target, token); - if (neighbours == null || neighbours?.Length == 0) - { - if (_logger.IsTrace) _logger.Trace("Empty result"); - continue; - } - - int queryIgnored = 0; - int seenIgnored = 0; - foreach (TNode neighbour in neighbours!) - { - ValueHash256 neighbourHash = keyOperator.GetNodeHash(neighbour); - - // Already queried, we ignore - if (queried.ContainsKey(neighbourHash)) - { - queryIgnored++; - continue; - } - - // When seen already dont record - if (!seen.TryAdd(neighbourHash, neighbour)) - { - seenIgnored++; - continue; - } - - totalResult++; - yield return neighbour; - - bool foundBetter = comparer.Compare(neighbourHash, bestNodeId) < 0; - queryQueue.Enqueue((neighbourHash, neighbour), neighbourHash); - - // If found a better node, reset closes node round. - // This causes `ShouldStopDueToNoBetterResult` to return false. - if (closestNodeRound < currentRound && foundBetter) - { - if (_logger.IsTrace) - _logger.Trace($"Found better neighbour {neighbour} at round {currentRound}."); - bestNodeId = neighbourHash; - closestNodeRound = currentRound; - } - } - - if (_logger.IsTrace) - _logger.Trace($"Count {neighbours.Length}, queried {queryIgnored}, seen {seenIgnored}"); - - if (ShouldStop()) - { - if (_logger.IsTrace) _logger.Trace("Stopping lookup. No better result."); - break; - } - } - - if (_logger.IsTrace) _logger.Trace("Lookup operation finished."); - yield break; - - bool ShouldStop() - { - int round = ++currentRound; - if (totalResult >= MinResult && round - closestNodeRound >= MaxNonProgressingRound) - { - // No closer node for more than or equal to _alpha*2 round. - // Assume exit condition - // Why not just _alpha? - // Because there could be currently running work that may increase closestNodeRound. - // So including this worker, assume no more - if (_logger.IsTrace) _logger.Trace($"No more closer node. Round: {round}, closestNodeRound {closestNodeRound}"); - return true; - } - - return round >= MaxRounds; - } - } - - async Task FindNeighbour(TNode node, TKey target, CancellationToken token) - { - try - { - ValueHash256 nodeHash = keyOperator.GetNodeHash(node); - if (_unreachableNodes.TryGet(nodeHash, out DateTimeOffset lastAttempt) && - lastAttempt + TimeSpan.FromMinutes(5) > timestamper.UtcNowOffset) - { - return []; - } - - TNode[]? result = await msgSender.FindNeighbours(node, target, token); - if (result is null) - { - _unreachableNodes.Set(nodeHash, timestamper.UtcNowOffset); - } - - return result; - } - catch (OperationCanceledException) when (!token.IsCancellationRequested) - { - _unreachableNodes.Set(keyOperator.GetNodeHash(node), timestamper.UtcNowOffset); - return null; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - if (_logger.IsDebug) _logger.Debug($"Find neighbour op failed. {e}"); - return null; - } - } - -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucket.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucket.cs deleted file mode 100644 index 2eeff2a902ec..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KBucket.cs +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Crypto; - -namespace Nethermind.Network.Discovery.Kademlia; - -public class KBucket(int k) where TNode : notnull -{ - private readonly int _k = k; - private DoubleEndedLru _items = new(k); - private DoubleEndedLru _replacement = new(k); - - public int Count => _items.Count; - - private TNode[] _cachedArray = []; - - /// - /// Add or refresh a node entry. - /// Used when any traffic is received, or when seeding a node. - /// Return the last entry in a bucket to refresh when bucket is full. - /// - /// - /// - public BucketAddResult TryAddOrRefresh(in ValueHash256 hash, TNode item, out TNode? toRefresh) - { - BucketAddResult addResult = _items.AddOrRefresh(hash, item); - if (addResult == BucketAddResult.Added) - { - _cachedArray = _items.GetAll(); - } - - // Either added or refreshed - if (addResult != BucketAddResult.Full) - { - toRefresh = default; - return addResult; - } - - _replacement.AddOrRefresh(hash, item); - _items.TryGetLast(out toRefresh); - return BucketAddResult.Full; - } - - public TNode[] GetAll() => _cachedArray; - - public (ValueHash256, TNode)[] GetAllWithHash() => _items.GetAllWithHash(); - - public bool RemoveAndReplace(in ValueHash256 hash) - { - if (!_items.Remove(hash)) return false; - - if (_replacement.TryPopHead(out ValueHash256 replacementHash, out TNode? replacement)) - { - _items.AddOrRefresh(replacementHash, replacement!); - } - _cachedArray = _items.GetAll(); - - return true; - } - - public void Clear() - { - _items = new DoubleEndedLru(_k); - _replacement = new DoubleEndedLru(_k); - _cachedArray = _items.GetAll(); - } - - public bool ContainsNode(in ValueHash256 hash) => _items.Contains(hash); - - public TNode? GetByHash(ValueHash256 hash) => _items.GetByHash(hash); -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Kademlia.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Kademlia.cs deleted file mode 100644 index 5c44d005403d..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/Kademlia.cs +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Diagnostics; -using Nethermind.Core; -using Nethermind.Core.Crypto; -using Nethermind.Logging; - -namespace Nethermind.Network.Discovery.Kademlia; - -public class Kademlia : IKademlia where TNode : notnull -{ - private readonly IKademliaMessageSender _kademliaMessageSender; - private readonly IKeyOperator _keyOperator; - private readonly IRoutingTable _routingTable; - private readonly ILookupAlgo _lookupAlgo; - private readonly INodeHealthTracker _nodeHealthTracker; - private readonly ILogger _logger; - - private readonly TNode _currentNodeId; - private readonly ValueHash256 _currentNodeIdAsHash; - private readonly int _kSize; - private readonly TimeSpan _refreshInterval; - private readonly TimeSpan _bucketRefreshInterval; - private readonly IReadOnlyList _bootNodes; - private readonly ITimestamper _timestamper; - private readonly Dictionary _lastBucketRefreshTicks = []; - private readonly object _lastBucketRefreshLock = new(); - - public Kademlia( - IKeyOperator keyOperator, - IKademliaMessageSender sender, - IRoutingTable routingTable, - ILookupAlgo lookupAlgo, - ILogManager logManager, - INodeHealthTracker nodeHealthTracker, - ITimestamper timestamper, - KademliaConfig config) - { - _keyOperator = keyOperator; - _kademliaMessageSender = sender; - _routingTable = routingTable; - _lookupAlgo = lookupAlgo; - _nodeHealthTracker = nodeHealthTracker; - _logger = logManager.GetClassLogger>(); - - _currentNodeId = config.CurrentNodeId; - _currentNodeIdAsHash = _keyOperator.GetNodeHash(_currentNodeId); - _kSize = config.KSize; - _refreshInterval = config.RefreshInterval; - _bucketRefreshInterval = config.BucketRefreshInterval; - _bootNodes = config.BootNodes; - _timestamper = timestamper; - - AddOrRefresh(_currentNodeId); - } - - public TNode CurrentNode => _currentNodeId; - - public void AddOrRefresh(TNode node) => _nodeHealthTracker.OnIncomingMessageFrom(node); - - public void Remove(TNode node) => _routingTable.Remove(_keyOperator.GetNodeHash(node)); - - public TNode[] GetAllAtDistance(int i) => _routingTable.GetAllAtDistance(i); - - private bool SameAsSelf(TNode node) => _keyOperator.GetNodeHash(node) == _currentNodeIdAsHash; - - public Task LookupNodesClosest(TKey key, CancellationToken token, int? k = null) => _lookupAlgo.Lookup( - _keyOperator.GetKeyHash(key), - k ?? _kSize, - async (nextNode, token) => - { - if (SameAsSelf(nextNode)) - { - ValueHash256 keyHash = _keyOperator.GetKeyHash(key); - return _routingTable.GetKNearestNeighbour(keyHash); - } - return await _kademliaMessageSender.FindNeighbours(nextNode, key, token); - }, - token - ); - - public async Task Run(CancellationToken token) - { - while (true) - { - try - { - await Bootstrap(token); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - if (_logger.IsError) _logger.Error("Bootstrap iteration failed.", e); - } - - await Task.Delay(_refreshInterval, token); - } - } - - public async Task Bootstrap(CancellationToken token) - { - Stopwatch sw = Stopwatch.StartNew(); - - int onlineBootNodes = 0; - - // Check bootnodes is online - await Parallel.ForEachAsync(_bootNodes, token, async (node, token) => - { - try - { - // Should be added on Pong. - if (await _kademliaMessageSender.Ping(node, token)) - { - System.Threading.Interlocked.Increment(ref onlineBootNodes); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - if (_logger.IsDebug) _logger.Debug($"Bootnode ping failed for {node}. {e}"); - } - }); - - if (_logger.IsDebug) _logger.Debug($"Online bootnodes: {onlineBootNodes}"); - - TKey currentNodeIdAsKey = _keyOperator.GetKey(_currentNodeId); - await LookupNodesClosest(currentNodeIdAsKey, token); - - token.ThrowIfCancellationRequested(); - - // Refresh stale non-empty buckets one by one. A refresh means to do a k-nearest node lookup for a random hash - // for that particular bucket. - foreach ((ValueHash256 Prefix, int Distance, KBucket Bucket) in _routingTable.IterateBuckets()) - { - if (!ShouldRefreshBucket(Prefix, Bucket)) continue; - - TKey? keyToLookup = _keyOperator.CreateRandomKeyAtDistance(Prefix, Distance); - await LookupNodesClosest(keyToLookup, token); - } - - if (_logger.IsDebug) - { - _logger.Debug($"Bootstrap completed. Took {sw}."); - _routingTable.LogDebugInfo(); - } - } - - private bool ShouldRefreshBucket(ValueHash256 prefix, KBucket bucket) - { - if (bucket.Count == 0) return false; - - long nowTicks = _timestamper.UtcNow.Ticks; - lock (_lastBucketRefreshLock) - { - if (_lastBucketRefreshTicks.TryGetValue(prefix, out long lastRefreshTicks) && - nowTicks - lastRefreshTicks < _bucketRefreshInterval.Ticks) - { - return false; - } - - _lastBucketRefreshTicks[prefix] = nowTicks; - return true; - } - } - - public TNode[] GetKNeighbour(TKey target, TNode? excluding = default, bool excludeSelf = false) - { - ValueHash256? excludeHash = null; - if (excluding != null) excludeHash = _keyOperator.GetNodeHash(excluding); - ValueHash256 hash = _keyOperator.GetKeyHash(target); - return _routingTable.GetKNearestNeighbour(hash, excludeHash, excludeSelf); - } - - public event EventHandler OnNodeAdded - { - add => _routingTable.OnNodeAdded += value; - remove => _routingTable.OnNodeAdded -= value; - } - - public event EventHandler OnNodeRemoved - { - add => _routingTable.OnNodeRemoved += value; - remove => _routingTable.OnNodeRemoved -= value; - } - - public IEnumerable IterateNodes() - { - foreach ((ValueHash256 _, int _, KBucket Bucket) in _routingTable.IterateBuckets()) - { - foreach (TNode node in Bucket.GetAll()) - { - yield return node; - } - } - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaModule.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaModule.cs index 33c8e5a88f11..4a389b7459cb 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaModule.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/KademliaModule.cs @@ -3,6 +3,7 @@ using Autofac; using Nethermind.Core; +using Nethermind.Kademlia; namespace Nethermind.Network.Discovery.Kademlia; @@ -10,7 +11,8 @@ namespace Nethermind.Network.Discovery.Kademlia; /// A kademlia module. /// Application is expected to expose a /// - -/// - +/// - +/// - /// - /// for the table bootstrap and maintenance to function. /// Call to start the table. @@ -21,18 +23,21 @@ namespace Nethermind.Network.Discovery.Kademlia; /// /// Key is the type that represent the target or hash. /// Type of the node. -public class KademliaModule : Module where TNode : notnull +/// Type of the key-space value used by the routing table. +public class KademliaModule : Module + where TNode : notnull + where TKadKey : notnull { protected override void Load(ContainerBuilder builder) { base.Load(builder); builder - .AddSingleton, Kademlia>() - .AddSingleton, LookupKNearestNeighbour>() - .AddSingleton, FromKeyNodeHashProvider>() - .AddSingleton, KBucketTree>() - .AddSingleton, IteratorNodeLookup>() - .AddSingleton, NodeHealthTracker>(); + .AddSingleton, Kademlia>() + .AddSingleton, RandomWalkKademliaDiscovery>() + .AddSingleton, LookupKNearestNeighbour>() + .AddSingleton, FromKeyNodeHashProvider>() + .AddSingleton, KBucketTree>() + .AddSingleton, NodeHealthTracker>(); } } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/LookupKNearestNeighbour.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/LookupKNearestNeighbour.cs deleted file mode 100644 index 7529521ac15c..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/LookupKNearestNeighbour.cs +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Diagnostics.CodeAnalysis; -using Nethermind.Core.Crypto; -using Nethermind.Core.Threading; -using Nethermind.Logging; -using NonBlocking; - -namespace Nethermind.Network.Discovery.Kademlia; - -/// -/// This find nearest k query does not follow the kademlia paper faithfully. Instead of distinct rounds, it has -/// num worker where alpha is the number of worker. Worker does not wait for other worker. Stop condition -/// happens if no more node to query or no new node can be added to the current result set that can improve it -/// for more than alpha*2 request. It is slightly faster than the legacy query on find value where it can be cancelled -/// earlier as it converge to the content faster, but take more query for findnodes due to a more strict stop -/// condition. -/// -public class LookupKNearestNeighbour( - IRoutingTable routingTable, - INodeHashProvider nodeHashProvider, - INodeHealthTracker nodeHealthTracker, - KademliaConfig config, - ILogManager logManager) : ILookupAlgo where TNode : notnull -{ - private readonly TimeSpan _findNeighbourHardTimeout = config.LookupFindNeighbourHardTimeout; - private readonly ILogger _logger = logManager.GetClassLogger>(); - - public async Task Lookup( - ValueHash256 targetHash, - int k, - Func> findNeighbourOp, - CancellationToken token - ) - { - if (_logger.IsDebug) _logger.Debug($"Initiate lookup for hash {targetHash}"); - - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); - token = cts.Token; - - ConcurrentDictionary queried = new(); - ConcurrentDictionary seen = new(); - - IComparer comparer = Comparer.Create((h1, h2) => - Hash256XorUtils.Compare(h1, h2, targetHash)); - IComparer comparerReverse = Comparer.Create((h1, h2) => - Hash256XorUtils.Compare(h2, h1, targetHash)); - - McsLock queueLock = new(); - - // Ordered by lowest distance. Will get popped for next round. - PriorityQueue<(ValueHash256, TNode), ValueHash256> bestSeen = new(comparer); - - // Ordered by highest distance. Added on result. Get popped as result. - PriorityQueue<(ValueHash256, TNode), ValueHash256> finalResult = new(comparerReverse); - - foreach (TNode node in routingTable.GetKNearestNeighbour(targetHash, default)) - { - ValueHash256 nodeHash = nodeHashProvider.GetHash(node); - seen.TryAdd(nodeHash, node); - bestSeen.Enqueue((nodeHash, node), nodeHash); - } - - TaskCompletionSource roundComplete = new(TaskCreationOptions.RunContinuationsAsynchronously); - int closestNodeRound = 0; - int currentRound = 0; - int queryingTask = 0; - bool finished = false; - - Task[] worker = [.. Enumerable.Range(0, config.Alpha).Select((i) => Task.Run(async () => - { - while (!Volatile.Read(ref finished)) - { - token.ThrowIfCancellationRequested(); - if (!TryGetNodeToQuery(out (ValueHash256 hash, TNode node)? toQuery)) - { - if (queryingTask > 0) - { - // Need to wait for all querying tasks first here. - await Task.WhenAny(Volatile.Read(ref roundComplete).Task, Task.Delay(100, token)); - continue; - } - - // No node to query and running query. - if (_logger.IsTrace) _logger.Trace("Stopping lookup. No node to query."); - break; - } - - try - { - if (ShouldStopDueToNoBetterResult(out int round)) - { - if (_logger.IsTrace) _logger.Trace("Stopping lookup. No better result."); - break; - } - - queried.TryAdd(toQuery.Value.hash, toQuery.Value.node); - (TNode, TNode[]? neighbours)? result = await WrappedFindNeighbourOp(toQuery.Value.node); - if (result == null) continue; - - ProcessResult(toQuery.Value.hash, toQuery.Value.node, result, round); - } - finally - { - Interlocked.Decrement(ref queryingTask); - TaskCompletionSource current = Volatile.Read(ref roundComplete); - if (current.TrySetResult()) - { - Interlocked.CompareExchange( - ref roundComplete, - new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), - current); - } - } - } - }, token))]; - - // When any of the worker is finished, we consider the whole query as done. - // This prevent this operation from hanging on a timed out request - await Task.WhenAny(worker); - Volatile.Write(ref finished, true); - await cts.CancelAsync(); - - return CompileResult(); - - async Task<(TNode target, TNode[]? retVal)> WrappedFindNeighbourOp(TNode node) - { - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(token); - cts.CancelAfter(_findNeighbourHardTimeout); - - try - { - // targetHash is implied in findNeighbourOp - TNode[]? ret = await findNeighbourOp(node, cts.Token); - if (ret is null) return (node, null); - - nodeHealthTracker.OnIncomingMessageFrom(node); - - return (node, ret); - } - catch (OperationCanceledException) when (!token.IsCancellationRequested) - { - nodeHealthTracker.OnRequestFailed(node); - return (node, null); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - nodeHealthTracker.OnRequestFailed(node); - if (_logger.IsWarn) _logger.Warn($"Find neighbour op failed. {e}"); - if (_logger.IsDebug) _logger.Debug($"Find neighbour op failed. {e}"); - return (node, null); - } - } - - bool TryGetNodeToQuery([NotNullWhen(true)] out (ValueHash256, TNode)? toQuery) - { - using McsLock.Disposable _ = queueLock.Acquire(); - if (bestSeen.Count == 0) - { - toQuery = default; - // No more node to query. - // Note: its possible that there are other worker currently which may add to bestSeen. - return false; - } - - Interlocked.Increment(ref queryingTask); - toQuery = bestSeen.Dequeue(); - return true; - } - - void ProcessResult(ValueHash256 hash, TNode toQuery, (TNode, TNode[]? neighbours)? valueTuple, int round) - { - using McsLock.Disposable _ = queueLock.Acquire(); - - finalResult.Enqueue((hash, toQuery), hash); - while (finalResult.Count > k) - { - finalResult.Dequeue(); - } - - TNode[]? neighbours = valueTuple?.neighbours; - if (neighbours == null) return; - - foreach (TNode neighbour in neighbours) - { - ValueHash256 neighbourHash = nodeHashProvider.GetHash(neighbour); - - // Already queried, we ignore - if (queried.ContainsKey(neighbourHash)) continue; - - // When seen already dont record - if (!seen.TryAdd(neighbourHash, neighbour)) continue; - - bestSeen.Enqueue((neighbourHash, neighbour), neighbourHash); - - if (closestNodeRound < round) - { - if (finalResult.Count < k) - { - closestNodeRound = round; - } - - // If the worst item in final result is worst that this neighbour, update closes node round - if (finalResult.TryPeek(out (ValueHash256 hash, TNode node) worstResult, out ValueHash256 _) && comparer.Compare(neighbourHash, worstResult.hash) < 0) - { - closestNodeRound = round; - } - } - } - } - - TNode[] CompileResult() - { - using McsLock.Disposable _ = queueLock.Acquire(); - if (finalResult.Count > k) finalResult.Dequeue(); - return [.. finalResult.UnorderedItems.Select((kv) => kv.Element.Item2)]; - } - - bool ShouldStopDueToNoBetterResult(out int round) - { - using McsLock.Disposable _ = queueLock.Acquire(); - - round = Interlocked.Increment(ref currentRound); - if (finalResult.Count >= k && round - closestNodeRound >= (config.Alpha * 2)) - { - // No closer node for more than or equal to _alpha*2 round. - // Assume exit condition - // Why not just _alpha? - // Because there could be currently running work that may increase closestNodeRound. - // So including this worker, assume no more - if (_logger.IsTrace) _logger.Trace($"No more closer node. Round: {round}, closestNodeRound {closestNodeRound}"); - return true; - } - - return false; - } - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/NodeHealthTracker.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/NodeHealthTracker.cs deleted file mode 100644 index 2dee63b28843..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/NodeHealthTracker.cs +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Nethermind.Core.Caching; -using Nethermind.Core.Crypto; -using Nethermind.Logging; -using NonBlocking; - -namespace Nethermind.Network.Discovery.Kademlia; - -public class NodeHealthTracker( - KademliaConfig config, - IRoutingTable routingTable, - INodeHashProvider nodeHashProvider, - IKademliaMessageSender kademliaMessageSender, - ILogManager logManager -) : INodeHealthTracker where TNode : notnull -{ - private readonly ILogger _logger = logManager.GetClassLogger>(); - - private readonly ConcurrentDictionary _isRefreshing = new(); - private readonly LruCache _peerFailures = new(1024, "peer failure"); - private readonly ValueHash256 _currentNodeIdAsHash = nodeHashProvider.GetHash(config.CurrentNodeId); - - private bool SameAsSelf(TNode node) => nodeHashProvider.GetHash(node) == _currentNodeIdAsHash; - - private void TryRefresh(TNode toRefresh) - { - ValueHash256 nodeHash = nodeHashProvider.GetHash(toRefresh); - if (_isRefreshing.TryAdd(nodeHash, true)) - { - Task.Run(async () => - { - // First, we delay in case any new message come and clear the refresh task, so we don't need to send any ping. - await Task.Delay(100); - if (!_isRefreshing.ContainsKey(nodeHash)) - { - return; - } - - // OK, fine, we'll ping it. - try - { - if (await kademliaMessageSender.Ping(toRefresh, CancellationToken.None)) - { - OnIncomingMessageFrom(toRefresh); - } - } - catch (Exception e) - { - OnRequestFailed(toRefresh); - if (_logger.IsDebug) _logger.Debug($"Error while refreshing node {toRefresh}, {e}"); - } - - if (_isRefreshing.TryRemove(nodeHash, out _)) - { - routingTable.Remove(nodeHash); - } - }); - } - } - - /// - /// Call when an incoming message from a node is received. This is used by other algorithm for health checks. - /// - /// - public void OnIncomingMessageFrom(TNode node) - { - _isRefreshing.TryRemove(nodeHashProvider.GetHash(node), out _); - - BucketAddResult addResult = routingTable.TryAddOrRefresh(nodeHashProvider.GetHash(node), node, out TNode? toRefresh); - if (addResult == BucketAddResult.Full && toRefresh != null) - { - if (SameAsSelf(toRefresh)) - { - // Move the current node entry to the front of its bucket. - routingTable.TryAddOrRefresh(_currentNodeIdAsHash, toRefresh, out TNode? _); - } - else - { - TryRefresh(toRefresh); - } - } - _peerFailures.Delete(nodeHashProvider.GetHash(node)); - } - - /// - /// Call when a request to a node failed. This is used by other algorithm for health checks. - /// - /// - public void OnRequestFailed(TNode node) - { - ValueHash256 hash = nodeHashProvider.GetHash(node); - if (!_peerFailures.TryGet(hash, out int currentFailure)) - { - _peerFailures.Set(hash, 1); - return; - } - - if (currentFailure >= config.NodeRequestFailureThreshold) - { - routingTable.Remove(hash); - _peerFailures.Delete(hash); - return; - } - - _peerFailures.Set(hash, currentFailure + 1); - } -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Discv4/PublicKeyKeyOperator.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/PublicKeyKeyOperator.cs similarity index 63% rename from src/Nethermind/Nethermind.Network.Discovery/Discv4/PublicKeyKeyOperator.cs rename to src/Nethermind/Nethermind.Network.Discovery/Kademlia/PublicKeyKeyOperator.cs index 1b0556dd9153..8afc478b936d 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Discv4/PublicKeyKeyOperator.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/PublicKeyKeyOperator.cs @@ -1,17 +1,18 @@ // SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Runtime.CompilerServices; using Nethermind.Core.Crypto; -using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Kademlia; using Nethermind.Stats.Model; -namespace Nethermind.Network.Discovery.Discv4; +namespace Nethermind.Network.Discovery.Kademlia; -public class PublicKeyKeyOperator : IKeyOperator +public sealed class PublicKeyKeyOperator : IKeyOperator { public PublicKey GetKey(Node node) => node.Id; - public ValueHash256 GetKeyHash(PublicKey key) => key.Hash; + public Hash256 GetKeyHash(PublicKey key) => key.Hash; /// /// Creates a random discv4 lookup target. @@ -21,9 +22,10 @@ public class PublicKeyKeyOperator : IKeyOperator /// Constructing a public key whose Keccak hash lands in that prefix is not practical, so this uses a random /// 64-byte target and treats discv4 bucket refresh as best-effort sampling. /// - public PublicKey CreateRandomKeyAtDistance(ValueHash256 nodePrefix, int depth) + [SkipLocalsInit] + public PublicKey CreateRandomKeyAtDistance(Hash256 nodePrefix, int depth) { - Span randomBytes = new byte[64]; + Span randomBytes = stackalloc byte[PublicKey.LengthInBytes]; Random.Shared.NextBytes(randomBytes); return new PublicKey(randomBytes); } diff --git a/src/Nethermind/Nethermind.Network.Discovery/Kademlia/RecentNodeFilter.cs b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/RecentNodeFilter.cs new file mode 100644 index 000000000000..71883ea01bf3 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Kademlia/RecentNodeFilter.cs @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace Nethermind.Network.Discovery.Kademlia; + +internal static class RecentNodeFilter +{ + private const int MaxBucketSizeForLimit = 16; + + public static int GetLimit(int bucketSize, int maxDistance, int minimumCount) + => Math.Max(minimumCount, Math.Min(bucketSize, MaxBucketSizeForLimit) * maxDistance); +} + +internal sealed class RecentNodeFilter(int maxCount) + where TKey : notnull +{ + private readonly Dictionary _nodes = new(maxCount); + private readonly Lock _lock = new(); + private Queue<(TKey NodeId, long Generation)> _recentNodes = new(maxCount); + private long _generation; + + public bool TryReserve(TKey nodeId) + { + lock (_lock) + { + if (_nodes.ContainsKey(nodeId)) + { + return false; + } + + long generation = unchecked(++_generation); + _nodes.Add(nodeId, generation); + _recentNodes.Enqueue((nodeId, generation)); + Trim(); + + return true; + } + } + + public void Release(TKey nodeId) + { + lock (_lock) + { + _nodes.Remove(nodeId); + DropReleasedHeadEntries(); + if (_recentNodes.Count > Math.Max(maxCount * 2, 256)) + { + CompactQueue(); + } + } + } + + private void Trim() + { + DropReleasedHeadEntries(); + while (_nodes.Count > maxCount && _recentNodes.TryDequeue(out (TKey NodeId, long Generation) oldestNode)) + { + if (_nodes.TryGetValue(oldestNode.NodeId, out long generation) && generation == oldestNode.Generation) + { + _nodes.Remove(oldestNode.NodeId); + } + } + } + + private void DropReleasedHeadEntries() + { + while (_recentNodes.TryPeek(out (TKey NodeId, long Generation) oldestNode) && + (!_nodes.TryGetValue(oldestNode.NodeId, out long generation) || generation != oldestNode.Generation)) + { + _recentNodes.Dequeue(); + } + } + + private void CompactQueue() + { + Queue<(TKey NodeId, long Generation)> compacted = new(_nodes.Count); + foreach ((TKey NodeId, long Generation) node in _recentNodes) + { + if (_nodes.TryGetValue(node.NodeId, out long generation) && generation == node.Generation) + { + compacted.Enqueue(node); + } + } + + _recentNodes = compacted; + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/KademliaDiscoveryApp.cs b/src/Nethermind/Nethermind.Network.Discovery/KademliaDiscoveryApp.cs new file mode 100644 index 000000000000..43286fd50b7c --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/KademliaDiscoveryApp.cs @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using DotNetty.Transport.Channels; +using Nethermind.Config; +using Nethermind.Core; +using Nethermind.Core.Crypto; +using Nethermind.Core.ServiceStopper; +using Nethermind.Kademlia; +using Nethermind.Logging; +using Nethermind.Network.Config; +using Nethermind.Network.Discovery.Kademlia; +using Nethermind.Stats.Model; + +namespace Nethermind.Network.Discovery; + +public abstract class KademliaDiscoveryApp( + string description, + INetworkConfig networkConfig, + IIPResolver ipResolver, + IProcessExitSource processExitSource, + ILogger logger) : IDiscoveryApp, IAsyncDisposable +{ + private readonly string _description = description; + private readonly INetworkConfig _networkConfig = networkConfig; + private readonly IIPResolver _ipResolver = ipResolver; + private readonly CancellationTokenSource _stopCts = CancellationTokenSource.CreateLinkedTokenSource(processExitSource.Token); + private IKademliaNodeSource? _kademliaNodeSource; + private IKademlia? _kademlia; + private Task? _runningTask; + private Task? _stopTask; + private Task? _disposeTask; + private readonly object _lifetimeLock = new(); + private int _activationStarted; + + protected ILogger Logger { get; } = logger; + + protected IKademlia Kademlia => _kademlia ?? throw new InvalidOperationException("Kademlia services were not initialized."); + + public async Task StartAsync() + { + try + { + await Initialize(_stopCts.Token); + TryStartActivation(); + } + catch (Exception e) + { + Logger.Error($"Error during {_description} app start process", e); + throw; + } + } + + public Task StopAsync() + { + lock (_lifetimeLock) + { + return _stopTask ??= StopAsyncInternal(); + } + } + + private async Task StopAsyncInternal() + { + DetachEventHandlers(); + + await _stopCts.CancelAsync(); + + try + { + if (_runningTask is not null) + { + await _runningTask; + } + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + if (Logger.IsError) Logger.Error($"Error in {_description} task", e); + } + + try + { + await StopAsyncCore(); + } + finally + { + _stopCts.Dispose(); + } + + if (Logger.IsInfo) Logger.Info($"{_description} shutdown complete. Please wait for all components to close"); + } + + string IStoppableService.Description => _description; + + public abstract void InitializeChannel(IChannel channel); + + public virtual void AddNodeToDiscovery(Node node) => Kademlia.AddOrRefresh(node); + + public IAsyncEnumerable DiscoverNodes(CancellationToken token) + => (_kademliaNodeSource ?? throw new InvalidOperationException("Kademlia services were not initialized.")).DiscoverNodes(token); + + public event EventHandler? NodeRemoved; + + public ValueTask DisposeAsync() + { + lock (_lifetimeLock) + { + return new ValueTask(_disposeTask ??= DisposeAsyncInternal()); + } + } + + private async Task DisposeAsyncInternal() + { + try + { + await StopAsync(); + } + finally + { + if (_kademlia is not null) + { + _kademlia.OnNodeRemoved -= OnKademliaNodeRemoved; + } + + await DisposeAsyncCore(); + } + } + + protected void UseKademliaServices(IKademliaNodeSource kademliaNodeSource, IKademlia kademlia) + { + _kademliaNodeSource = kademliaNodeSource; + _kademlia = kademlia; + _kademlia.OnNodeRemoved += OnKademliaNodeRemoved; + } + + protected virtual async Task Initialize(CancellationToken cancellationToken) + { + IIPResolver.NethermindIp ip = await _ipResolver.Resolve(cancellationToken); + + if (Logger.IsDebug) Logger.Debug($"Discovery : udp://{ip.ExternalIp}:{_networkConfig.DiscoveryPort}"); + + ThisNodeInfo.AddInfo("Discovery :", $"udp://{ip.ExternalIp}:{_networkConfig.DiscoveryPort}"); + } + + protected void OnChannelActivated(object? sender, EventArgs e) + { + if (Logger.IsDebug) Logger.Debug("Activated discovery channel."); + + if (_stopCts.IsCancellationRequested) + { + return; + } + + TryStartActivation(); + } + + private void TryStartActivation() + { + if (_stopCts.IsCancellationRequested || + Interlocked.CompareExchange(ref _activationStarted, 1, 0) != 0) + { + return; + } + + _runningTask = StartActivationAsync(_stopCts.Token); + } + + protected virtual void DetachEventHandlers() + { + } + + protected virtual Task StopAsyncCore() => Task.CompletedTask; + + protected virtual ValueTask DisposeAsyncCore() => ValueTask.CompletedTask; + + protected abstract Task RunDiscoveryAsync(CancellationToken cancellationToken); + + private async Task StartActivationAsync(CancellationToken cancellationToken) + { + const string faultMessage = "Cannot activate channel."; + + try + { + await Task.Factory.StartNew(static state => ((KademliaDiscoveryApp)state!).ActivateAsync(), this, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + if (!cancellationToken.IsCancellationRequested && Logger.IsDebug) Logger.Debug($"{_description} App initialized."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception) + { + if (Logger.IsInfo) Logger.Info(faultMessage); + throw; + } + } + + private Task ActivateAsync() => ActivateAsync(_stopCts.Token); + + private async Task ActivateAsync(CancellationToken cancellationToken) + { + try + { + await RunDiscoveryAsync(cancellationToken); + } + catch (OperationCanceledException) + { + if (Logger.IsInfo) Logger.Info($"{_description} App stopped"); + } + catch (Exception e) + { + Logger.DebugError($"Error during {_description} initialization", e); + } + } + + private void OnKademliaNodeRemoved(object? sender, Node node) => NodeRemoved?.Invoke(sender, new NodeEventArgs(node)); +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Messages/PongMsg.cs b/src/Nethermind/Nethermind.Network.Discovery/Messages/PongMsg.cs deleted file mode 100644 index e02c08f6bec4..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/Messages/PongMsg.cs +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Net; -using Nethermind.Core.Crypto; -using Nethermind.Core.Extensions; - -namespace Nethermind.Network.Discovery.Messages; - -public class PongMsg : DiscoveryMsg -{ - public byte[] PingMdc { get; init; } - - public PongMsg(IPEndPoint farAddress, long expirationTime, byte[] pingMdc) : base(farAddress, expirationTime) => PingMdc = pingMdc ?? throw new ArgumentNullException(nameof(pingMdc)); - - public PongMsg(PublicKey farPublicKey, long expirationTime, byte[] pingMdc) : base(farPublicKey, expirationTime) => PingMdc = pingMdc ?? throw new ArgumentNullException(nameof(pingMdc)); - - public override string ToString() => base.ToString() + $", PingMdc: {PingMdc?.ToHexString() ?? "empty"}"; - - public override MsgType MsgType => MsgType.Pong; -} diff --git a/src/Nethermind/Nethermind.Network.Discovery/Nethermind.Network.Discovery.csproj b/src/Nethermind/Nethermind.Network.Discovery/Nethermind.Network.Discovery.csproj index 63a3cddbf7a8..10167006d534 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/Nethermind.Network.Discovery.csproj +++ b/src/Nethermind/Nethermind.Network.Discovery/Nethermind.Network.Discovery.csproj @@ -1,6 +1,7 @@ + true enable enable @@ -14,12 +15,13 @@ + - + diff --git a/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryBaseHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryBaseHandler.cs index d733d4fe6a17..1c9967ab9534 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryBaseHandler.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/NettyDiscoveryBaseHandler.cs @@ -8,14 +8,33 @@ namespace Nethermind.Network.Discovery; -public abstract class NettyDiscoveryBaseHandler(ILogManager? logManager) : SimpleChannelInboundHandler +public abstract class NettyDiscoveryBaseHandler(ILogManager? logManager, IChannel? channel = null) : SimpleChannelInboundHandler { private readonly ILogger _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); + private IChannel? _channel = channel; // https://github.com/ethereum/devp2p/blob/master/discv4.md#wire-protocol // https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire.md#udp-communication protected const int MaxPacketSize = 1280; + protected IChannel Channel => _channel ?? throw new InvalidOperationException("Discovery channel is not initialized."); + + public void InitializeChannel(IChannel channel) => _channel = channel; + + public override void ChannelActive(IChannelHandlerContext context) => OnChannelActivated?.Invoke(this, EventArgs.Empty); + + public override void ChannelInactive(IChannelHandlerContext context) + { + CloseInbound(); + base.ChannelInactive(context); + } + + public override void HandlerRemoved(IChannelHandlerContext context) + { + CloseInbound(); + base.HandlerRemoved(context); + } + public override void ChannelRead(IChannelHandlerContext ctx, object msg) { if (msg is DatagramPacket packet && AcceptInboundMessage(packet) && !ValidatePacket(packet)) @@ -39,4 +58,10 @@ protected bool ValidatePacket(DatagramPacket packet) return true; } + + protected virtual void CloseInbound() + { + } + + public event EventHandler? OnChannelActivated; } diff --git a/src/Nethermind/Nethermind.Network.Discovery/NodeRecordProvider.cs b/src/Nethermind/Nethermind.Network.Discovery/NodeRecordProvider.cs index 4ed0ee594087..f822b1e5000b 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/NodeRecordProvider.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/NodeRecordProvider.cs @@ -8,7 +8,7 @@ namespace Nethermind.Network.Discovery; -public class NodeRecordProvider( +public sealed class NodeRecordProvider( [KeyFilter(IProtectedPrivateKey.NodeKey)] IProtectedPrivateKey nodeKey, IIPResolver ipResolver, IEthereumEcdsa ethereumEcdsa, diff --git a/src/Nethermind/Nethermind.Network.Discovery/NullDiscoveryApp.cs b/src/Nethermind/Nethermind.Network.Discovery/NullDiscoveryApp.cs index b488ef6b142b..d0abeb0c4358 100644 --- a/src/Nethermind/Nethermind.Network.Discovery/NullDiscoveryApp.cs +++ b/src/Nethermind/Nethermind.Network.Discovery/NullDiscoveryApp.cs @@ -7,7 +7,7 @@ namespace Nethermind.Network.Discovery; -public class NullDiscoveryApp : IDiscoveryApp +public sealed class NullDiscoveryApp : IDiscoveryApp { public void Initialize(PublicKey masterPublicKey) { diff --git a/src/Nethermind/Nethermind.Network.Discovery/Serializers/IPAddressRlp.cs b/src/Nethermind/Nethermind.Network.Discovery/Serializers/IPAddressRlp.cs new file mode 100644 index 000000000000..844564be9928 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Discovery/Serializers/IPAddressRlp.cs @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Discovery.Serializers; + +internal static class IPAddressRlp +{ + public static int GetLength(IPAddress ip) + => ip.AddressFamily switch + { + AddressFamily.InterNetwork => Rlp.LengthOfByteString(4, 0), + AddressFamily.InterNetworkV6 => Rlp.LengthOfByteString(16, 0), + _ => Rlp.LengthOf(ip.GetAddressBytes()) + }; + + [SkipLocalsInit] + public static void Encode(RlpStream stream, IPAddress ip) + { + Span bytes = stackalloc byte[16]; + if (ip.TryWriteBytes(bytes, out int bytesWritten)) + { + stream.Encode(bytes[..bytesWritten]); + return; + } + + stream.Encode(ip.GetAddressBytes()); + } +} diff --git a/src/Nethermind/Nethermind.Network.Discovery/TalkReqAndRespHandler.cs b/src/Nethermind/Nethermind.Network.Discovery/TalkReqAndRespHandler.cs deleted file mode 100644 index deb036d3fdd7..000000000000 --- a/src/Nethermind/Nethermind.Network.Discovery/TalkReqAndRespHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using Lantern.Discv5.WireProtocol.Messages; - -namespace Nethermind.Network.Discovery; - -/// https://github.com/ethereum/devp2p/blob/master/discv5/discv5-wire.md#talkreq-request-0x05 -internal class TalkReqAndRespHandler : ITalkReqAndRespHandler -{ - //Must send an empty response if no protocols are matched - private static readonly byte[][] EmptyProtocolResponse = [[]]; - - public byte[][]? HandleRequest(byte[] protocol, byte[] request) => - //We currently don't advertise any supported protocols - EmptyProtocolResponse; - - // We don't care about anything returned here at the moment - public byte[]? HandleResponse(byte[] response) => []; -} diff --git a/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordSignerTests.cs b/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordSignerTests.cs index dca9b9586aaf..22776a5cc79d 100644 --- a/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordSignerTests.cs +++ b/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordSignerTests.cs @@ -3,6 +3,7 @@ using System; using System.Buffers.Binary; +using System.Collections.Generic; using System.Net; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; @@ -14,10 +15,7 @@ namespace Nethermind.Network.Enr.Test; public class NodeRecordSignerTests { - [SetUp] - public void Setup() - { - } + private const string TestPrivateKey = "b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"; [Test(Description = "https://eips.ethereum.org/EIPS/eip-778")] public void Is_correct_on_eip_test_vector() @@ -39,7 +37,7 @@ public void Is_correct_on_eip_test_vector() Console.WriteLine("expected: " + expectedHexString); Ecdsa ecdsa = new(); - PrivateKey privateKey = new("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"); + PrivateKey privateKey = new(TestPrivateKey); NodeRecordSigner signer = new(ecdsa, privateKey); NodeRecord nodeRecord = new(); nodeRecord.SetEntry(new IpEntry( @@ -75,7 +73,7 @@ public void Can_verify_signature() Console.WriteLine("expected: " + expectedHexString); Ecdsa ecdsa = new(); - PrivateKey privateKey = new("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"); + PrivateKey privateKey = new(TestPrivateKey); NodeRecordSigner signer = new(ecdsa, privateKey); NodeRecord nodeRecord = new(); @@ -96,21 +94,11 @@ public void Can_verify_signature() Assert.That(signer.Verify(nodeRecord), Is.True); } - [TestCase] - public void Throws_when_record_is_t() + [TestCaseSource(nameof(InvalidRecordRlpCases))] + public void Throws_when_record_is_invalid(Func createRecord, Type exceptionType) { - Ecdsa ecdsa = new(); - PrivateKey privateKey = new("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"); - NodeRecordSigner signer = new(ecdsa, privateKey); - Span bytes = Bytes.FromHexString("540b38f8b160f23b1cd30972338a09ba4a296e2f0cb63f76ce0b38201a8dd9aa2a9c306370904877ddab397f7845ff67ea0a1dbf094b86794bb5d739e6bda891a486098717e2fb744e04c4665d307a590c6e4141a3805de15eb1eb62b0c6ff0aa75db9559545e294e158b7dc9e4a118cf0c2c6259af2df7c1742731064df376182b2df2e714df9e87ec6492effb4de8e2a92bdb405bbe3d8ddf96622bbcb11592fdb2600356cb39fd2c36cac66e19cd1b136ac3be993ef0ed07905d95f16cc67cfbe9bc7c180b90023d55d9218bef9e052c9f655a5c2464abe24271cc1dc2f3df7d3abd926f4657b724b0435868a09f7136ec115cbc3ec1c675972315e4cc140907e4772c118d51917b16a00a7809cfa767ea3ae5557c0b972c37f77d85062910e3e15ae4613cac178220deadc6d729da20c85166e8532d8f88cd246e6102f5268cd5e29796d06713d0f684e096e5edfca6b6c7adf9e51e10f5140d92216123eb31984a61d5a9caf904a2e12f3f479b27d75aeafe0d35b8995468aa12ba7d8f17fbb0aeea63b4d2c74e43b60e06a62bed5ee3ae34f5d74465087b5932865a2cb41f1fdaa9b2b9143fe1923d7f0e4b18a3139ee469df8e6cfea46101674e5fde4c84f9f9d77dee3d0545897a69d9eb42ccc48b699baa9d932dc36783da3580a78abc68b20a1f8bda90afb5ed78a9ac46e63792182b7669e4daaf3ca7e9b5690a3bbf0a184b14470f899582d4a0423897a295441b4bf27db3d2e8adf41824538942198a064bc489fd0936e11f5266146432a8efc992e1d304a4ab6bf661fa1ab3b59d1f14155c5e6a8d1e9eed717bee86a9b6bdabde638c0d1"); - RlpStream rlpStream = new(600); - rlpStream.StartSequence(500); - rlpStream.Encode(bytes[..500]); - rlpStream.Position = 0; - Assert.That( - () => signer.Deserialize(rlpStream), - Throws.TypeOf() - .With.Property(nameof(NetworkingException.NetworkExceptionType)).EqualTo(NetworkExceptionType.Discovery)); + NodeRecordSigner signer = new(new Ecdsa()); + Assert.That(() => signer.Deserialize(createRecord()), Throws.TypeOf(exceptionType)); } [TestCase("f897b840421561b4ed5de28a7100e0a5005ecc0ba6ba6cc18528061e811704c8794fec965cba63831051d134bdc801c0c90d31a30d241074095311ffe6628d5545478b770a83657468c7c68496516d06808269648276348269708436ed0a0a89736563703235366b31a103f5c110132b0374805d4453f55577cc9c58bb1a08f822b9b3722132e3095f69728374637082765f8375647082765f")] @@ -119,7 +107,7 @@ public void Throws_when_record_is_t() public void Can_deserialize_and_verify_real_world_cases(string testCase) { Ecdsa ecdsa = new(); - PrivateKey privateKey = new("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"); + PrivateKey privateKey = new(TestPrivateKey); NodeRecordSigner signer = new(ecdsa, privateKey); RlpStream rlpStream = Bytes.FromHexString(testCase).AsRlpStream(); NodeRecord nodeRecord = signer.Deserialize(rlpStream); @@ -127,15 +115,251 @@ public void Can_deserialize_and_verify_real_world_cases(string testCase) Console.WriteLine(testCase); Console.WriteLine(hex); Assert.That(signer.Verify(nodeRecord), Is.True); + Assert.That(nodeRecord.ToRlpBytes(), Is.EqualTo(Bytes.FromHexString(testCase))); + } + + [Test] + public void Can_serialize_eth_entry_as_nested_fork_id_list() + { + byte[] forkHash = [1, 2, 3, 4]; + const long nextBlock = 0x0506; + byte[] expectedEntryBytes = Bytes.FromHexString("83657468c9c88401020304820506"); + + Ecdsa ecdsa = new(); + PrivateKey privateKey = new(TestPrivateKey); + NodeRecordSigner signer = new(ecdsa, privateKey); + NodeRecord nodeRecord = new(); + nodeRecord.SetEntry(new EthEntry(forkHash, nextBlock)); + nodeRecord.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + signer.Sign(nodeRecord); + + byte[] recordBytes = nodeRecord.ToRlpBytes(); + Assert.That(recordBytes.AsSpan().IndexOf(expectedEntryBytes), Is.GreaterThanOrEqualTo(0)); + + NodeRecord decoded = NodeRecord.FromBytes(recordBytes, ecdsa); + ForkId? forkId = decoded.GetValue(EnrContentKey.Eth); + + Assert.That(forkId, Is.Not.Null); + Assert.That(forkId.Value.ForkHash, Is.EqualTo(forkHash)); + Assert.That(forkId.Value.NextBlock, Is.EqualTo(nextBlock)); } + [TestCaseSource(nameof(InvalidRecordByteCases))] + public void FromBytes_throws_when_record_bytes_are_invalid(Func createRecordBytes) + => Assert.That(() => NodeRecord.FromBytes(createRecordBytes()), Throws.TypeOf()); [Test] public void Cannot_verify_when_signature_missing() { - PrivateKey privateKey = new("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"); + PrivateKey privateKey = new(TestPrivateKey); NodeRecordSigner signer = new(new Ecdsa(), privateKey); NodeRecord nodeRecord = new(); Assert.Throws(() => _ = signer.Verify(nodeRecord)); } + + private static RlpStream CreateRecord(params (string Key, Action EncodeValue, int ValueLength)[] entries) + { + byte[] signature = new byte[64]; + int contentLength = Rlp.LengthOf(signature) + Rlp.LengthOf(1UL); + foreach ((string key, _, int valueLength) in entries) + { + contentLength += Rlp.LengthOf(key) + valueLength; + } + + RlpStream rlpStream = new(Rlp.LengthOfSequence(contentLength)); + rlpStream.StartSequence(contentLength); + rlpStream.Encode(signature); + rlpStream.Encode(1UL); + foreach ((string key, Action encodeValue, _) in entries) + { + rlpStream.Encode(key); + encodeValue(rlpStream); + } + + rlpStream.Position = 0; + return rlpStream; + } + + private static IEnumerable InvalidRecordRlpCases() + { + yield return InvalidRecordCase( + CreateNonSequenceRecord, + typeof(RlpException), + "Throws_when_record_is_not_a_sequence"); + + yield return InvalidRecordCase( + CreateRecordWithDeclaredLengthOverLimit, + typeof(RlpException), + "Throws_when_declared_record_payload_is_bigger_than_300_bytes"); + + yield return InvalidRecordCase( + CreateEncodedRecordOverLimit, + typeof(RlpException), + "Throws_when_encoded_record_is_bigger_than_300_bytes"); + + yield return InvalidRecordCase( + CreateRecordWithOversizedSignature, + typeof(RlpLimitException), + "Throws_when_signature_is_too_long"); + + yield return InvalidRecordCase( + static () => CreateRecord( + (EnrContentKey.Udp, static stream => stream.Encode(30303), Rlp.LengthOf(30303)), + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4"))), + typeof(RlpException), + "Throws_when_keys_are_not_sorted"); + + yield return InvalidRecordCase( + static () => CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4")), + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4"))), + typeof(RlpException), + "Throws_when_keys_are_duplicated"); + + yield return InvalidRecordCase( + static () => CreateRecord( + ("z", static stream => stream.Encode(Array.Empty()), Rlp.LengthOf(Array.Empty()))), + typeof(RlpException), + "Throws_when_id_is_missing"); + + yield return InvalidRecordCase( + static () => CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode(string.Empty), Rlp.LengthOf(string.Empty))), + typeof(RlpException), + "Throws_when_id_is_empty"); + + yield return InvalidRecordCase( + static () => CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode("V4"), Rlp.LengthOf("V4"))), + typeof(RlpException), + "Throws_when_id_has_wrong_case"); + + yield return InvalidRecordCase( + static () => CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode("v5"), Rlp.LengthOf("v5"))), + typeof(RlpException), + "Throws_when_id_is_not_v4"); + } + + private static IEnumerable InvalidRecordByteCases() + { + yield return new TestCaseData((Func)CreateRecordWithTrailingBytes) + .SetName("FromBytes_throws_when_record_has_trailing_bytes"); + + yield return new TestCaseData((Func)CreateRecordWithUnrecoverableSignature) + .SetName("FromBytes_throws_when_signature_cannot_recover"); + + yield return new TestCaseData((Func)CreateRecordWithInvalidSignature) + .SetName("FromBytes_throws_when_signature_does_not_match_public_key"); + } + + private static RlpStream CreateNonSequenceRecord() + { + RlpStream rlpStream = new(Rlp.LengthOf(EnrContentKey.Id)); + rlpStream.Encode(EnrContentKey.Id); + rlpStream.Position = 0; + return rlpStream; + } + + private static RlpStream CreateRecordWithDeclaredLengthOverLimit() + { + Span bytes = Bytes.FromHexString("540b38f8b160f23b1cd30972338a09ba4a296e2f0cb63f76ce0b38201a8dd9aa2a9c306370904877ddab397f7845ff67ea0a1dbf094b86794bb5d739e6bda891a486098717e2fb744e04c4665d307a590c6e4141a3805de15eb1eb62b0c6ff0aa75db9559545e294e158b7dc9e4a118cf0c2c6259af2df7c1742731064df376182b2df2e714df9e87ec6492effb4de8e2a92bdb405bbe3d8ddf96622bbcb11592fdb2600356cb39fd2c36cac66e19cd1b136ac3be993ef0ed07905d95f16cc67cfbe9bc7c180b90023d55d9218bef9e052c9f655a5c2464abe24271cc1dc2f3df7d3abd926f4657b724b0435868a09f7136ec115cbc3ec1c675972315e4cc140907e4772c118d51917b16a00a7809cfa767ea3ae5557c0b972c37f77d85062910e3e15ae4613cac178220deadc6d729da20c85166e8532d8f88cd246e6102f5268cd5e29796d06713d0f684e096e5edfca6b6c7adf9e51e10f5140d92216123eb31984a61d5a9caf904a2e12f3f479b27d75aeafe0d35b8995468aa12ba7d8f17fbb0aeea63b4d2c74e43b60e06a62bed5ee3ae34f5d74465087b5932865a2cb41f1fdaa9b2b9143fe1923d7f0e4b18a3139ee469df8e6cfea46101674e5fde4c84f9f9d77dee3d0545897a69d9eb42ccc48b699baa9d932dc36783da3580a78abc68b20a1f8bda90afb5ed78a9ac46e63792182b7669e4daaf3ca7e9b5690a3bbf0a184b14470f899582d4a0423897a295441b4bf27db3d2e8adf41824538942198a064bc489fd0936e11f5266146432a8efc992e1d304a4ab6bf661fa1ab3b59d1f14155c5e6a8d1e9eed717bee86a9b6bdabde638c0d1"); + RlpStream rlpStream = new(600); + rlpStream.StartSequence(500); + rlpStream.Encode(bytes[..500]); + rlpStream.Position = 0; + return rlpStream; + } + + private static RlpStream CreateEncodedRecordOverLimit() + { + byte[] filler = FindFillerForOversizedEncodedRecord(); + return CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4")), + ("z", stream => stream.Encode(filler), Rlp.LengthOf(filler))); + } + + private static RlpStream CreateRecordWithOversizedSignature() + => CreateRecordWithSignatureLength(66, + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4"))); + + private static RlpStream CreateRecordWithSignatureLength( + int signatureLength, + params (string Key, Action EncodeValue, int ValueLength)[] entries) + { + byte[] signature = new byte[signatureLength]; + int contentLength = Rlp.LengthOf(signature) + Rlp.LengthOf(1UL); + foreach ((string key, _, int valueLength) in entries) + { + contentLength += Rlp.LengthOf(key) + valueLength; + } + + RlpStream rlpStream = new(Rlp.LengthOfSequence(contentLength)); + rlpStream.StartSequence(contentLength); + rlpStream.Encode(signature); + rlpStream.Encode(1UL); + foreach ((string key, Action encodeValue, _) in entries) + { + rlpStream.Encode(key); + encodeValue(rlpStream); + } + + rlpStream.Position = 0; + return rlpStream; + } + + private static byte[] CreateRecordWithTrailingBytes() + { + byte[] recordBytes = Bytes.FromHexString( + "f897b840421561b4ed5de28a7100e0a5005ecc0ba6ba6cc18528061e811704c8794fec965cba63831051d134bdc801c0c90d31a30d241074095311ffe6628d5545478b770a83657468c7c68496516d06808269648276348269708436ed0a0a89736563703235366b31a103f5c110132b0374805d4453f55577cc9c58bb1a08f822b9b3722132e3095f69728374637082765f8375647082765f"); + return [.. recordBytes, 0x80]; + } + + private static byte[] CreateRecordWithUnrecoverableSignature() + { + byte[] publicKey = new byte[CompressedPublicKey.LengthInBytes]; + RlpStream rlpStream = CreateRecord( + (EnrContentKey.Id, static stream => stream.Encode("v4"), Rlp.LengthOf("v4")), + (EnrContentKey.SecP256k1, stream => stream.Encode(publicKey), Rlp.LengthOf(publicKey))); + + return rlpStream.Data.AsSpan().ToArray(); + } + + private static byte[] CreateRecordWithInvalidSignature() + { + Ecdsa ecdsa = new(); + PrivateKey privateKey = new(TestPrivateKey); + NodeRecordSigner signer = new(ecdsa, privateKey); + NodeRecord nodeRecord = new(); + nodeRecord.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + signer.Sign(nodeRecord); + + byte[] recordBytes = nodeRecord.ToRlpBytes().AsSpan().ToArray(); + recordBytes[4] ^= 0x01; + return recordBytes; + } + + private static TestCaseData InvalidRecordCase(Func createRecord, Type exceptionType, string name) + => new TestCaseData(createRecord, exceptionType).SetName(name); + + private static byte[] FindFillerForOversizedEncodedRecord() + { + for (int i = 0; i <= 300; i++) + { + byte[] filler = new byte[i]; + int contentLength = + Rlp.LengthOf(new byte[64]) + + Rlp.LengthOf(1UL) + + Rlp.LengthOf(EnrContentKey.Id) + + Rlp.LengthOf("v4") + + Rlp.LengthOf("z") + + Rlp.LengthOf(filler); + if (contentLength <= 300 && Rlp.LengthOfSequence(contentLength) > 300) + { + return filler; + } + } + + throw new InvalidOperationException("Could not create oversized ENR fixture."); + } } diff --git a/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordTests.cs b/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordTests.cs index 57253746699c..0d2b179d22bf 100644 --- a/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordTests.cs +++ b/src/Nethermind/Nethermind.Network.Enr.Test/NodeRecordTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Net; using Nethermind.Core.Crypto; using NUnit.Framework; @@ -42,6 +43,44 @@ public void Cannot_get_enr_string_when_signature_missing() Assert.Throws(() => _ = nodeRecord.EnrString); } + [TestCase("192.0.2.1", "", -1, 30304, "", -1)] + [TestCase("", "2001:db8::1", 30303, -1, "2001:db8::1", 30303)] + public void Discovery_endpoint_uses_expected_ip_udp_fallback(string ip, string ip6, int udp, int udp6, string expectedIp, int expectedPort) + { + NodeRecord nodeRecord = new(); + + if (!string.IsNullOrEmpty(ip)) + { + nodeRecord.SetEntry(new IpEntry(IPAddress.Parse(ip))); + } + + if (!string.IsNullOrEmpty(ip6)) + { + nodeRecord.SetEntry(new Ip6Entry(IPAddress.Parse(ip6))); + } + + if (udp >= 0) + { + nodeRecord.SetEntry(new UdpEntry(udp)); + } + + if (udp6 >= 0) + { + nodeRecord.SetEntry(new Udp6Entry(udp6)); + } + + if (expectedPort < 0) + { + Assert.That(nodeRecord.DiscoveryIp, Is.Null); + Assert.That(nodeRecord.DiscoveryPort, Is.Null); + } + else + { + Assert.That(nodeRecord.DiscoveryIp, Is.EqualTo(IPAddress.Parse(expectedIp))); + Assert.That(nodeRecord.DiscoveryPort, Is.EqualTo(expectedPort)); + } + } + [Test] public void Enr_content_entry_has_hash_code() { diff --git a/src/Nethermind/Nethermind.Network.Enr/EnrContentKey.cs b/src/Nethermind/Nethermind.Network.Enr/EnrContentKey.cs index 95824f8c6070..c08805cb48fc 100644 --- a/src/Nethermind/Nethermind.Network.Enr/EnrContentKey.cs +++ b/src/Nethermind/Nethermind.Network.Enr/EnrContentKey.cs @@ -14,6 +14,12 @@ public static class EnrContentKey public const string Eth = "eth"; public static ReadOnlySpan EthU8 => "eth"u8; + /// + /// Consensus-layer information. + /// + public const string Eth2 = "eth2"; + public static ReadOnlySpan Eth2U8 => "eth2"u8; + /// /// Name of identity scheme, e.g. "v4" /// diff --git a/src/Nethermind/Nethermind.Network.Enr/EthEntry.cs b/src/Nethermind/Nethermind.Network.Enr/EthEntry.cs index 058a328df499..0101f3694074 100644 --- a/src/Nethermind/Nethermind.Network.Enr/EthEntry.cs +++ b/src/Nethermind/Nethermind.Network.Enr/EthEntry.cs @@ -12,17 +12,20 @@ public class EthEntry(byte[] forkHash, long nextBlock) : EnrContentEntry { public override string Key => EnrContentKey.Eth; - protected override int GetRlpLengthOfValue() => Rlp.LengthOfSequence( - Rlp.LengthOfSequence( - 5 + Rlp.LengthOf(Value.NextBlock))); + protected override int GetRlpLengthOfValue() + { + int forkIdContentLength = GetForkIdContentLength(); + return Rlp.LengthOfSequence(Rlp.LengthOfSequence(forkIdContentLength)); + } protected override void EncodeValue(RlpStream rlpStream) { - // I am just guessing this one - int contentLength = 5 + Rlp.LengthOf(Value.NextBlock); - rlpStream.StartSequence(contentLength + 1); + int contentLength = GetForkIdContentLength(); + rlpStream.StartSequence(Rlp.LengthOfSequence(contentLength)); rlpStream.StartSequence(contentLength); rlpStream.Encode(Value.ForkHash); rlpStream.Encode(Value.NextBlock); } + + private int GetForkIdContentLength() => Rlp.LengthOf(Value.ForkHash) + Rlp.LengthOf(Value.NextBlock); } diff --git a/src/Nethermind/Nethermind.Network.Enr/Ip6Entry.cs b/src/Nethermind/Nethermind.Network.Enr/Ip6Entry.cs new file mode 100644 index 000000000000..822f7549fbf3 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Enr/Ip6Entry.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Enr; + +/// +/// An entry storing the IPv6 address of the node. +/// +public class Ip6Entry(IPAddress ipAddress) : EnrContentEntry(ipAddress) +{ + public override string Key => EnrContentKey.Ip6; + + protected override int GetRlpLengthOfValue() => 17; + + protected override void EncodeValue(RlpStream rlpStream) + { + Span bytes = stackalloc byte[16]; + Value.MapToIPv6().TryWriteBytes(bytes, out int _); + rlpStream.Encode(bytes); + } +} diff --git a/src/Nethermind/Nethermind.Network.Enr/Nethermind.Network.Enr.csproj b/src/Nethermind/Nethermind.Network.Enr/Nethermind.Network.Enr.csproj index 24fcd5b1e3dc..f28a1362d4de 100644 --- a/src/Nethermind/Nethermind.Network.Enr/Nethermind.Network.Enr.csproj +++ b/src/Nethermind/Nethermind.Network.Enr/Nethermind.Network.Enr.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Nethermind/Nethermind.Network.Enr/NodeRecord.cs b/src/Nethermind/Nethermind.Network.Enr/NodeRecord.cs index 110e2f7437d7..bf0882c2c40e 100644 --- a/src/Nethermind/Nethermind.Network.Enr/NodeRecord.cs +++ b/src/Nethermind/Nethermind.Network.Enr/NodeRecord.cs @@ -1,13 +1,11 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -using System.Text; -using DotNetty.Buffers; -using DotNetty.Codecs.Base64; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Crypto; using Nethermind.Serialization.Rlp; +using System.Net; namespace Nethermind.Network.Enr; @@ -16,27 +14,26 @@ namespace Nethermind.Network.Enr; /// public class NodeRecord { - private long _enrSequence; + private static readonly IEcdsa DefaultEcdsa = new Ecdsa(); + + private ulong _enrSequence; private string? _enrString; private Hash256? _contentHash; - private SortedDictionary Entries { get; } = new(System.StringComparer.Ordinal); + private Signature? _signature; - /// - /// This field is used when this is deserialized and an unknown entry is encountered. - /// In such cases we do not know the RLP serialization format of such an entry and we store the original RLP - /// in order to be able to verify the signature. I think that we may replace it by Keccak(OriginalContentRlp). - /// - public byte[]? OriginalContentRlp { get; set; } + private SortedDictionary Entries { get; } = new(StringComparer.Ordinal); + + internal byte[]? OriginalRlp { get; set; } /// /// Represents the version / id / sequence of the node record data. It should be increased by one with each /// update to the node data. Setting sequence on this class wipes out and /// . /// - public long EnrSequence + public ulong EnrSequence { get => _enrSequence; set @@ -84,12 +81,145 @@ private Hash256 CalculateContentHash() /// /// A signature resulting from a secp256k1 signing of the [seq, k, v, ...] content. /// - public Signature? Signature { get; set; } + public Signature? Signature + { + get => _signature; + set + { + _signature = value; + OriginalRlp = null; + _enrString = null; + _contentHash = null; + } + } public bool Snap { get; set; } public NodeRecord() => SetEntry(IdEntry.Instance); + /// + /// Gets the IP address advertised for discovery traffic. + /// + /// + /// IPv4 is preferred when both ip and udp are present. Otherwise IPv6 is returned when it has a + /// discovery port, with udp as the EIP-778 fallback. + /// + public IPAddress? DiscoveryIp => GetDiscoveryEndpoint().Ip; + + /// + /// Gets the UDP port advertised for discovery traffic. + /// + /// + /// For IPv6, udp6 is preferred and udp is used as the EIP-778 fallback. + /// + public int? DiscoveryPort => GetDiscoveryEndpoint().Port; + + /// + /// Gets the IP address advertised for RLPx TCP traffic. + /// + /// + /// IPv4 is preferred when both ip and tcp are present. Otherwise IPv6 is returned when it has a + /// TCP port, with tcp as the EIP-778 fallback. + /// + public IPAddress? TcpIp => GetTcpEndpoint().Ip; + + /// + /// Gets the TCP port advertised for RLPx traffic. + /// + /// + /// For IPv6, tcp6 is preferred and tcp is used as the EIP-778 fallback. + /// + public int? TcpPort => GetTcpEndpoint().Port; + + private (IPAddress? Ip, int? Port) GetDiscoveryEndpoint() + { + IPAddress? ip = GetObj(EnrContentKey.Ip); + int? udp = GetValue(EnrContentKey.Udp); + if (ip is not null && udp is not null) + { + return (ip, udp); + } + + IPAddress? ip6 = GetObj(EnrContentKey.Ip6); + int? udp6 = GetValue(EnrContentKey.Udp6); + if (ip6 is not null) + { + int? port = udp6 ?? udp; + return port is null ? (null, null) : (ip6, port); + } + + return (null, null); + } + + private (IPAddress? Ip, int? Port) GetTcpEndpoint() + { + IPAddress? ip = GetObj(EnrContentKey.Ip); + int? tcp = GetValue(EnrContentKey.Tcp); + if (ip is not null && tcp is not null) + { + return (ip, tcp); + } + + IPAddress? ip6 = GetObj(EnrContentKey.Ip6); + int? tcp6 = GetValue(EnrContentKey.Tcp6); + if (ip6 is not null) + { + int? port = tcp6 ?? tcp; + return port is null ? (null, null) : (ip6, port); + } + + return (null, null); + } + + public static NodeRecord FromEnrString(string enrString) + { + const string prefix = "enr:"; + if (!enrString.StartsWith(prefix, StringComparison.Ordinal)) + { + throw new ArgumentException("ENR must start with the 'enr:' prefix.", nameof(enrString)); + } + + string base64 = enrString[prefix.Length..].Replace('-', '+').Replace('_', '/'); + int padding = (4 - base64.Length % 4) % 4; + if (padding is not 0) + { + base64 = string.Concat(base64, new string('=', padding)); + } + + NodeRecord nodeRecord = FromBytes(Convert.FromBase64String(base64)); + nodeRecord._enrString = enrString; + return nodeRecord; + } + + public static NodeRecord FromBytes(ReadOnlySpan bytes) + => FromBytes(bytes, DefaultEcdsa); + + public static NodeRecord FromBytes(byte[] bytes) + => FromBytes(bytes.AsSpan(), DefaultEcdsa); + + public static NodeRecord FromBytes(byte[] bytes, IEcdsa ecdsa) + => FromBytes(bytes.AsSpan(), ecdsa); + + public static NodeRecord FromBytes(ReadOnlySpan bytes, IEcdsa ecdsa) + { + ArgumentNullException.ThrowIfNull(ecdsa); + + NodeRecordSigner signer = new(ecdsa); + Rlp.ValueDecoderContext ctx = new(bytes); + NodeRecord nodeRecord = signer.Deserialize(ref ctx); + if (ctx.Position != bytes.Length) + { + throw new RlpException("Unexpected trailing bytes in ENR."); + } + + if (!signer.Verify(nodeRecord)) + { + throw new RlpException("Invalid ENR signature."); + } + + return nodeRecord; + } + /// /// Sets one of the record entries. Entries are then automatically sorted by keys. /// @@ -102,8 +232,19 @@ public void SetEntry(EnrContentEntry entry) } Entries[entry.Key] = entry; + OriginalRlp = null; + _enrString = null; + _contentHash = null; + _signature = null; } + /// + /// Checks whether an ENR entry with the specified key is present. + /// + /// Key of the entry to check. + /// when the entry is present; otherwise . + public bool HasEntry(string entryKey) => Entries.ContainsKey(entryKey); + /// /// Gets a record entry value (in case of the value types). Use for reference types./> /// @@ -164,8 +305,8 @@ private int GetContentLengthWithoutSignature() /// Needed for optimized RLP serialization when a proper length byte array has to be allocated upfront. /// /// Length of the Rlp([signature, seq, k, v, ...]) - public int GetRlpLengthWithSignature() => Rlp.LengthOfSequence( - GetContentLengthWithSignature()); + public int GetRlpLengthWithSignature() => OriginalRlp?.Length ?? Rlp.LengthOfSequence( + GetContentLengthWithSignature()); /// /// Applies Rlp([seq, k, v, ...]]). @@ -186,13 +327,20 @@ private void EncodeContent(RlpStream rlpStream) /// Added here for diagnostic purposes - hes is easier to read and compare. /// /// Rlp([signature, seq, k, v, ...]) as a hex string - public string GetHex() + public string GetHex() => ToRlpBytes().AsSpan().ToHexString(); + + public byte[] ToRlpBytes() { - int contentLength = GetContentLengthWithSignature(); - int totalLength = Rlp.LengthOfSequence(contentLength); - RlpStream rlpStream = new(totalLength); + if (OriginalRlp is not null) + { + return OriginalRlp.ToArray(); + } + + int rlpLength = GetRlpLengthWithSignature(); + byte[] bytes = GC.AllocateUninitializedArray(rlpLength); + RlpStream rlpStream = new(bytes); Encode(rlpStream); - return rlpStream.Data.AsSpan().ToHexString(); + return bytes; } /// @@ -201,6 +349,12 @@ public string GetHex() /// An RLP stream to encode the content to. public void Encode(RlpStream rlpStream) { + if (OriginalRlp is not null) + { + rlpStream.Write(OriginalRlp); + return; + } + RequireSignature(); int contentLength = GetContentLengthWithSignature(); @@ -218,28 +372,9 @@ private string CreateEnrString() RequireSignature(); const string prefix = "enr:"; - int rlpLength = GetRlpLengthWithSignature(); - IByteBuffer buffer = NethermindBuffers.Default.Buffer(rlpLength); - try - { - NettyRlpStream rlpStream = new(buffer); - Encode(rlpStream); - IByteBuffer resultBuffer = Base64.Encode(buffer, Base64Dialect.URL_SAFE); - try - { - string base64String = resultBuffer.ReadString(resultBuffer.ReadableBytes, Encoding.UTF8); - int skipLast = base64String[^2] == '=' ? 2 : base64String[^1] == '=' ? 1 : 0; - return string.Concat(prefix, base64String.AsSpan(0, base64String.Length - skipLast)); - } - finally - { - resultBuffer.Release(); - } - } - finally - { - buffer.Release(); - } + string base64String = Convert.ToBase64String(ToRlpBytes()).Replace('+', '-').Replace('/', '_'); + int skipLast = base64String[^2] == '=' ? 2 : base64String[^1] == '=' ? 1 : 0; + return string.Concat(prefix, base64String.AsSpan(0, base64String.Length - skipLast)); } private void RequireSignature() diff --git a/src/Nethermind/Nethermind.Network.Enr/NodeRecordSigner.cs b/src/Nethermind/Nethermind.Network.Enr/NodeRecordSigner.cs index 2fff1a5c1dc0..51db861cba7a 100644 --- a/src/Nethermind/Nethermind.Network.Enr/NodeRecordSigner.cs +++ b/src/Nethermind/Nethermind.Network.Enr/NodeRecordSigner.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only using System.Net; +using System.Text; using Nethermind.Core.Crypto; using Nethermind.Crypto; using Nethermind.Serialization.Rlp; @@ -11,17 +12,26 @@ namespace Nethermind.Network.Enr; /// /// https://eips.ethereum.org/EIPS/eip-778 /// -public class NodeRecordSigner(IEcdsa? ethereumEcdsa, PrivateKey? privateKey) : INodeRecordSigner +public class NodeRecordSigner(IEcdsa? ethereumEcdsa, PrivateKey? privateKey = null) : INodeRecordSigner { private readonly IEcdsa _ecdsa = ethereumEcdsa ?? throw new ArgumentNullException(nameof(ethereumEcdsa)); - private readonly PrivateKey _privateKey = privateKey ?? throw new ArgumentNullException(nameof(privateKey)); + private readonly PrivateKey? _privateKey = privateKey; /// /// Signs the node record with own private key. /// /// - public void Sign(NodeRecord nodeRecord) => nodeRecord.Signature = _ecdsa.Sign(_privateKey, in nodeRecord.ContentHash.ValueHash256); + public void Sign(NodeRecord nodeRecord) + { + if (_privateKey is null) + { + throw new InvalidOperationException("Cannot sign an ENR without a private key."); + } + + nodeRecord.OriginalRlp = null; + nodeRecord.Signature = _ecdsa.Sign(_privateKey, in nodeRecord.ContentHash.ValueHash256); + } /// /// Deserializes a from an . @@ -45,29 +55,52 @@ public NodeRecord Deserialize(ref Rlp.ValueDecoderContext ctx) { int startPosition = ctx.Position; int recordRlpLength = ctx.ReadSequenceLength(); - if (recordRlpLength > 300) - throw new NetworkingException("RLP received for ENR is bigger than 300 bytes", NetworkExceptionType.Discovery); + int checkPosition = ctx.Position + recordRlpLength; + if (checkPosition - startPosition > 300) + throw new RlpException("RLP received for ENR is bigger than 300 bytes"); NodeRecord nodeRecord = new(); + ReadOnlySpan previousKey = default; ReadOnlySpan sigBytes = ctx.DecodeByteArraySpan(RlpLimit.L65); Signature signature = new(sigBytes, 0); - bool canVerify = true; - long enrSequence = ctx.DecodeLong(); - while (ctx.Position < startPosition + recordRlpLength) + bool hasV4Id = false; + ulong enrSequence = ctx.DecodeULong(); + while (ctx.Position < checkPosition) { ReadOnlySpan key = ctx.DecodeByteArraySpan(); + if (previousKey.Length != 0 && key.SequenceCompareTo(previousKey) <= 0) + { + throw new RlpException("ENR keys must be sorted and unique."); + } + previousKey = key; + switch (key.Length) { case 2 when key.SequenceEqual(EnrContentKey.IdU8): - ctx.SkipItem(); + ReadOnlySpan id = ctx.DecodeByteArraySpan(); + if (!id.SequenceEqual("v4"u8)) + { + throw new RlpException("Unsupported ENR identity scheme."); + } + + hasV4Id = true; nodeRecord.SetEntry(IdEntry.Instance); break; case 2 when key.SequenceEqual(EnrContentKey.IpU8): - ReadOnlySpan ipBytes = ctx.DecodeByteArraySpan(); - IPAddress address = new(ipBytes); - nodeRecord.SetEntry(new IpEntry(address)); - break; + { + ReadOnlySpan ipBytes = ctx.DecodeByteArraySpan(); + IPAddress address = new(ipBytes); + nodeRecord.SetEntry(new IpEntry(address)); + break; + } + case 3 when key.SequenceEqual(EnrContentKey.Ip6U8): + { + ReadOnlySpan ipBytes = ctx.DecodeByteArraySpan(); + IPAddress address = new(ipBytes); + nodeRecord.SetEntry(new Ip6Entry(address)); + break; + } case 3 when key.SequenceEqual(EnrContentKey.EthU8): _ = ctx.ReadSequenceLength(); _ = ctx.ReadSequenceLength(); @@ -76,44 +109,56 @@ public NodeRecord Deserialize(ref Rlp.ValueDecoderContext ctx) nodeRecord.SetEntry(new EthEntry(forkHash, nextBlock)); break; case 3 when key.SequenceEqual(EnrContentKey.TcpU8): - int tcpPort = ctx.DecodePositiveInt(); - nodeRecord.SetEntry(new TcpEntry(tcpPort)); - break; + { + int tcpPort = ctx.DecodePositiveInt(); + nodeRecord.SetEntry(new TcpEntry(tcpPort)); + break; + } + case 4 when key.SequenceEqual(EnrContentKey.Tcp6U8): + { + int tcpPort = ctx.DecodePositiveInt(); + nodeRecord.SetEntry(new Tcp6Entry(tcpPort)); + break; + } case 3 when key.SequenceEqual(EnrContentKey.UdpU8): - int udpPort = ctx.DecodePositiveInt(); - nodeRecord.SetEntry(new UdpEntry(udpPort)); - break; + { + int udpPort = ctx.DecodePositiveInt(); + nodeRecord.SetEntry(new UdpEntry(udpPort)); + break; + } + case 4 when key.SequenceEqual(EnrContentKey.Udp6U8): + { + int udpPort = ctx.DecodePositiveInt(); + nodeRecord.SetEntry(new Udp6Entry(udpPort)); + break; + } case 9 when key.SequenceEqual(EnrContentKey.SecP256k1U8): ReadOnlySpan keyBytes = ctx.DecodeByteArraySpan(); CompressedPublicKey reportedKey = new(keyBytes); nodeRecord.SetEntry(new SecP256k1Entry(reportedKey)); break; default: - // snap - canVerify = false; + int valueStart = ctx.Position; ctx.SkipItem(); + int valueLength = ctx.Position - valueStart; + nodeRecord.SetEntry(new UnknownEntry( + Encoding.UTF8.GetString(key), + ctx.Data.Slice(valueStart, valueLength).ToArray())); nodeRecord.Snap = true; break; } } - if (!canVerify) + ctx.Check(checkPosition); + if (!hasV4Id) { - ctx.Position = startPosition; - ctx.ReadSequenceLength(); - ctx.SkipItem(); // signature - int noSigContentLength = ctx.Length - ctx.Position; - int noSigSequenceLength = Rlp.LengthOfSequence(noSigContentLength); - byte[] originalContent = new byte[noSigSequenceLength]; - RlpStream originalContentStream = new(originalContent); - originalContentStream.StartSequence(noSigContentLength); - originalContentStream.Write(ctx.Read(noSigContentLength)); - ctx.Position = startPosition; - nodeRecord.OriginalContentRlp = originalContentStream.Data.ToArray()!; + throw new RlpException("ENR is missing id=v4."); } + int endPosition = ctx.Position; nodeRecord.EnrSequence = enrSequence; nodeRecord.Signature = signature; + nodeRecord.OriginalRlp = ctx.Data.Slice(startPosition, endPosition - startPosition).ToArray(); return nodeRecord; } @@ -133,25 +178,17 @@ public bool Verify(NodeRecord nodeRecord) throw new Exception("Cannot verify an ENR with an empty signature."); } - ValueHash256 contentHash; - if (nodeRecord.OriginalContentRlp is not null) - { - contentHash = ValueKeccak.Compute(nodeRecord.OriginalContentRlp); - } - else - { - contentHash = nodeRecord.ContentHash; - } + ValueHash256 contentHash = nodeRecord.ContentHash; - CompressedPublicKey publicKeyA = - _ecdsa.RecoverCompressedPublicKey(nodeRecord.Signature!, in contentHash)!; + CompressedPublicKey? publicKeyA = + _ecdsa.RecoverCompressedPublicKey(nodeRecord.Signature!, in contentHash); Signature sigB = new(nodeRecord.Signature!.Bytes, 1); - CompressedPublicKey publicKeyB = - _ecdsa.RecoverCompressedPublicKey(sigB, in contentHash)!; + CompressedPublicKey? publicKeyB = + _ecdsa.RecoverCompressedPublicKey(sigB, in contentHash); CompressedPublicKey? reportedKey = nodeRecord.GetObj(EnrContentKey.SecP256k1); - return publicKeyA.Equals(reportedKey) || publicKeyB.Equals(reportedKey); + return publicKeyA?.Equals(reportedKey) == true || publicKeyB?.Equals(reportedKey) == true; } } diff --git a/src/Nethermind/Nethermind.Network.Enr/Tcp6Entry.cs b/src/Nethermind/Nethermind.Network.Enr/Tcp6Entry.cs new file mode 100644 index 000000000000..85ab40b277f2 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Enr/Tcp6Entry.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Enr; + +/// +/// An entry storing TCP IPv6 port number. +/// +public class Tcp6Entry(int portNumber) : EnrContentEntry(portNumber) +{ + public override string Key => EnrContentKey.Tcp6; + + protected override int GetRlpLengthOfValue() => Rlp.LengthOf(Value); + + protected override void EncodeValue(RlpStream rlpStream) => rlpStream.Encode(Value); +} diff --git a/src/Nethermind/Nethermind.Network.Enr/Udp6Entry.cs b/src/Nethermind/Nethermind.Network.Enr/Udp6Entry.cs new file mode 100644 index 000000000000..864a7efadc33 --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Enr/Udp6Entry.cs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Enr; + +/// +/// An entry storing UDP IPv6 port number. +/// +public class Udp6Entry(int portNumber) : EnrContentEntry(portNumber) +{ + public override string Key => EnrContentKey.Udp6; + + protected override int GetRlpLengthOfValue() => Rlp.LengthOf(Value); + + protected override void EncodeValue(RlpStream rlpStream) => rlpStream.Encode(Value); +} diff --git a/src/Nethermind/Nethermind.Network.Enr/UnknownEntry.cs b/src/Nethermind/Nethermind.Network.Enr/UnknownEntry.cs new file mode 100644 index 000000000000..49944afb419b --- /dev/null +++ b/src/Nethermind/Nethermind.Network.Enr/UnknownEntry.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using Nethermind.Serialization.Rlp; + +namespace Nethermind.Network.Enr; + +internal sealed class UnknownEntry(string key, byte[] rlpValue) : EnrContentEntry(rlpValue) +{ + public override string Key { get; } = key; + + protected override int GetRlpLengthOfValue() => Value.Length; + + protected override void EncodeValue(RlpStream rlpStream) => rlpStream.Write(Value); +} diff --git a/src/Nethermind/Nethermind.Network.Stats/Model/Node.cs b/src/Nethermind/Nethermind.Network.Stats/Model/Node.cs index 56bc5a2e684c..89697aa89b0e 100644 --- a/src/Nethermind/Nethermind.Network.Stats/Model/Node.cs +++ b/src/Nethermind/Nethermind.Network.Stats/Model/Node.cs @@ -2,12 +2,14 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net; using System.Text.RegularExpressions; using FastEnumUtility; using Nethermind.Config; using Nethermind.Core.Crypto; +using Nethermind.Network.Enr; namespace Nethermind.Stats.Model { @@ -33,7 +35,7 @@ public sealed class Node : IFormattable, IEquatable /// /// Host part of the network node. /// - public string Host => _host ??= Address?.Address?.MapToIPv4()?.ToString(); + public string Host => _host ??= FormatHost(Address?.Address); private string _host; /// @@ -81,6 +83,45 @@ public string ClientId public Node(NetworkNode networkNode, bool isStatic = false) : this(networkNode.NodeId, networkNode.Host, networkNode.Port, isStatic) { + if (networkNode.IsEnr) + { + Enr = networkNode.Enr.EnrString; + } + } + + /// + /// Tries to create an RLPx peer candidate from an Ethereum Node Record with a secp256k1 key and TCP endpoint. + /// + /// The Ethereum Node Record to read. + /// The node created from the record when the record contains a usable TCP endpoint. + /// when a node could be created; otherwise . + public static bool TryFromEnr(NodeRecord enr, [MaybeNullWhen(false)] out Node node) + => TryFromEnrEndpoint(enr, enr.TcpIp, enr.TcpPort, out node); + + /// + /// Tries to create a discovery-routing node from an Ethereum Node Record with a secp256k1 key and UDP endpoint. + /// + /// The Ethereum Node Record to read. + /// The node created from the record when the record contains a usable UDP discovery endpoint. + /// when a node could be created; otherwise . + public static bool TryFromDiscoveryEnr(NodeRecord enr, [MaybeNullWhen(false)] out Node node) + => TryFromEnrEndpoint(enr, enr.DiscoveryIp, enr.DiscoveryPort, out node); + + private static bool TryFromEnrEndpoint(NodeRecord enr, IPAddress ip, int? port, [MaybeNullWhen(false)] out Node node) + { + node = null; + + PublicKey key = enr.GetObj(EnrContentKey.SecP256k1)?.Decompress(); + if (key is null || ip is null || port is null || port.Value == 0 || (uint)port.Value > ushort.MaxValue) + { + return false; + } + + node = new Node(key, new IPEndPoint(ip, port.Value)) + { + Enr = enr.EnrString + }; + return true; } public Node(PublicKey id, string host, int port, bool isStatic = false) @@ -118,6 +159,9 @@ private void SetIPEndPoint(IPEndPoint address) _paddedPort = null; } + private static string FormatHost(IPAddress address) + => address.IsIPv4MappedToIPv6 ? address.MapToIPv4().ToString() : address.ToString(); + // xxx.xxx.xxx.xxx = 15 private string PaddedHost => _paddedHost ??= Host.PadLeft(15, ' '); private string PaddedPort diff --git a/src/Nethermind/Nethermind.Network.Stats/Nethermind.Network.Stats.csproj b/src/Nethermind/Nethermind.Network.Stats/Nethermind.Network.Stats.csproj index 2f0d60944a8e..b77c105e8985 100644 --- a/src/Nethermind/Nethermind.Network.Stats/Nethermind.Network.Stats.csproj +++ b/src/Nethermind/Nethermind.Network.Stats/Nethermind.Network.Stats.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Nethermind/Nethermind.Network.Test/Builders/SerializationBuilder.cs b/src/Nethermind/Nethermind.Network.Test/Builders/SerializationBuilder.cs index 96efaf2edf86..df31da53ffcb 100644 --- a/src/Nethermind/Nethermind.Network.Test/Builders/SerializationBuilder.cs +++ b/src/Nethermind/Nethermind.Network.Test/Builders/SerializationBuilder.cs @@ -6,8 +6,8 @@ using Nethermind.Core.Specs; using Nethermind.Core.Test.Builders; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; -using Nethermind.Network.Discovery.Serializers; +using Nethermind.Network.Discovery.Discv4.Messages; +using Nethermind.Network.Discovery.Discv4.Serializers; using Nethermind.Network.P2P.Subprotocols.Eth.V62.Messages; using Nethermind.Network.P2P.Subprotocols.Eth.V63.Messages; using Nethermind.Network.P2P.Subprotocols.Eth.V65.Messages; diff --git a/src/Nethermind/Nethermind.Network.Test/CompositeNodeSourceTests.cs b/src/Nethermind/Nethermind.Network.Test/CompositeNodeSourceTests.cs index 12768a65ea94..f461be580a04 100644 --- a/src/Nethermind/Nethermind.Network.Test/CompositeNodeSourceTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/CompositeNodeSourceTests.cs @@ -1,6 +1,10 @@ // SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Nethermind.Core.Test.Builders; using Nethermind.Stats.Model; using NUnit.Framework; @@ -22,4 +26,18 @@ public void CompositeNodeSource_ShouldIgnoreNodeRemoved_AfterDispose() Assert.That(removedNode, Is.Null); } + + [Test] + [CancelAfter(10000)] + public async Task DiscoverNodes_ShouldStopInnerSources_WhenEnumerationIsDisposed(CancellationToken token) + { + TestNodeSource innerSource = new(); + CompositeNodeSource compositeNodeSource = new(innerSource); + Node node = new(TestItem.PublicKeyA, "1.2.3.4", 1234); + innerSource.AddNode(node); + + List nodes = await compositeNodeSource.DiscoverNodes(CancellationToken.None).Take(1).ToListAsync(token); + + Assert.That(nodes, Is.EqualTo(new[] { node })); + } } diff --git a/src/Nethermind/Nethermind.Network.Test/NetworkNodeDecoderTests.cs b/src/Nethermind/Nethermind.Network.Test/NetworkNodeDecoderTests.cs index eb317fba54e0..fb2d612791c7 100644 --- a/src/Nethermind/Nethermind.Network.Test/NetworkNodeDecoderTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/NetworkNodeDecoderTests.cs @@ -5,8 +5,11 @@ using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Network.Enr; using Nethermind.Serialization.Rlp; using NUnit.Framework; +using System.Net; namespace Nethermind.Network.Test { @@ -14,17 +17,12 @@ namespace Nethermind.Network.Test [TestFixture] public class NetworkNodeDecoderTests { - [Test] - public void Can_do_roundtrip() - { - NetworkNode node = new(TestItem.PublicKeyA, "127.0.0.1", 30303, 100L); - AssertRoundtripPreservesFields(node); - } - - [Test] - public void Can_do_roundtrip_negative_reputation() + [TestCase("127.0.0.1", 30303, 100L)] + [TestCase("127.0.0.1", 30303, -100L)] + [TestCase("127.0.0.1", -1, -100L)] + public void Can_do_roundtrip(string host, int port, long reputation) { - NetworkNode node = new(TestItem.PublicKeyA, "127.0.0.1", 30303, -100L); + NetworkNode node = new(TestItem.PublicKeyA, host, port, reputation); AssertRoundtripPreservesFields(node); } @@ -44,17 +42,10 @@ public void Can_read_regression() } } - [Test] - public void Negative_port_just_in_case_for_resilience() - { - NetworkNode node = new(TestItem.PublicKeyA, "127.0.0.1", -1, -100L); - AssertRoundtripPreservesFields(node); - } - private static void AssertRoundtripPreservesFields(NetworkNode node) { NetworkNodeDecoder networkNodeDecoder = new(); - Rlp encoded = Rlp.Encode(node); + Rlp encoded = networkNodeDecoder.Encode(node); Rlp.ValueDecoderContext context = encoded.Bytes.AsRlpValueContext(); NetworkNode decoded = networkNodeDecoder.Decode(ref context); using (Assert.EnterMultipleScope()) @@ -66,5 +57,45 @@ private static void AssertRoundtripPreservesFields(NetworkNode node) } } + [Test] + public void Can_do_enr_roundtrip() + { + NetworkNodeDecoder networkNodeDecoder = new(); + NodeRecord enr = CreateTestEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), 30303, 30304); + NetworkNode node = new(enr.EnrString) + { + Reputation = 100L + }; + + Rlp encoded = networkNodeDecoder.Encode(node); + Rlp.ValueDecoderContext context = encoded.Bytes.AsRlpValueContext(); + NetworkNode decoded = networkNodeDecoder.Decode(ref context); + + using (Assert.EnterMultipleScope()) + { + NodeRecord? decodedEnr = decoded.Enr; + Assert.That(decoded.IsEnr, Is.True); + Assert.That(decodedEnr, Is.Not.Null); + Assert.That(decodedEnr!.EnrString, Is.EqualTo(enr.EnrString)); + Assert.That(decoded.NodeId, Is.EqualTo(node.NodeId)); + Assert.That(decoded.Host, Is.EqualTo("8.8.8.8")); + Assert.That(decoded.Port, Is.EqualTo(30304)); + Assert.That(decoded.Reputation, Is.EqualTo(node.Reputation)); + } + } + + private static NodeRecord CreateTestEnr(PrivateKey privateKey, IPAddress ipAddress, int tcpPort, int udpPort) + { + NodeRecord enr = new(); + enr.SetEntry(IdEntry.Instance); + enr.SetEntry(new IpEntry(ipAddress)); + enr.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + enr.SetEntry(new TcpEntry(tcpPort)); + enr.SetEntry(new UdpEntry(udpPort)); + enr.EnrSequence = 1; + new NodeRecordSigner(new EthereumEcdsa(0), privateKey).Sign(enr); + + return enr; + } } } diff --git a/src/Nethermind/Nethermind.Network.Test/NetworkStorageTests.cs b/src/Nethermind/Nethermind.Network.Test/NetworkStorageTests.cs index 36147430c503..283ed34712d4 100644 --- a/src/Nethermind/Nethermind.Network.Test/NetworkStorageTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/NetworkStorageTests.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using Nethermind.Config; +using Nethermind.Core; using Nethermind.Core.Test.Builders; using Nethermind.Core.Test.IO; using Nethermind.Core.Timers; @@ -21,7 +22,6 @@ public class NetworkStorageTests [SetUp] public void SetUp() { - NetworkNodeDecoder.Init(); ILogManager logManager = LimboLogs.Instance; _ = new ConfigProvider(); _tempDir = TempPath.GetTempDirectory(); @@ -146,4 +146,76 @@ public void Can_store_peers() Assert.That(persistedNode.Reputation, Is.EqualTo(peer.Reputation)); } } + + [Test] + public void Start_batch_discards_pending_nodes_from_stale_batch() + { + NetworkStorage storage = new(new SnapshotableMemDb(), LimboLogs.Instance); + NetworkNode node = new(TestItem.PublicKeyA, "192.1.1.1", 3441, 0L); + + storage.StartBatch(); + storage.UpdateNode(node); + storage.StartBatch(); + + Assert.That(storage.GetPersistedNodes(), Is.Empty); + + storage.UpdateNode(node); + storage.Commit(); + + Assert.That(storage.GetPersistedNodes(), Has.Length.EqualTo(1)); + } + + [Test] + public void Failed_commit_reloads_persisted_nodes_before_new_updates() + { + FailingBatchDb db = new(); + NetworkStorage storage = new(db, LimboLogs.Instance); + NetworkNode persistedNode = new(TestItem.PublicKeyA, "192.1.1.1", 3441, 1L); + NetworkNode discardedNode = new(TestItem.PublicKeyB, "192.1.1.2", 3442, 2L); + NetworkNode pendingNode = new(TestItem.PublicKeyC, "192.1.1.3", 3443, 3L); + storage.UpdateNode(persistedNode); + + db.ThrowOnNextBatchDispose = true; + storage.StartBatch(); + storage.UpdateNode(discardedNode); + Assert.Throws(storage.Commit); + + storage.StartBatch(); + storage.UpdateNode(pendingNode); + + NetworkNode[] nodes = storage.GetPersistedNodes(); + using (Assert.EnterMultipleScope()) + { + Assert.That(nodes, Has.Some.Matches(node => node.NodeId.Equals(persistedNode.NodeId))); + Assert.That(nodes, Has.Some.Matches(node => node.NodeId.Equals(pendingNode.NodeId))); + Assert.That(nodes, Has.None.Matches(node => node.NodeId.Equals(discardedNode.NodeId))); + } + } + + private sealed class FailingBatchDb : MemDb + { + public bool ThrowOnNextBatchDispose { get; set; } + + public override IWriteBatch StartWriteBatch() + { + if (!ThrowOnNextBatchDispose) + { + return base.StartWriteBatch(); + } + + ThrowOnNextBatchDispose = false; + return new FailingWriteBatch(); + } + } + + private sealed class FailingWriteBatch : IWriteBatch + { + public void Clear() { } + + public void Dispose() => throw new InvalidOperationException("Failed batch dispose."); + + public void Set(ReadOnlySpan key, byte[]? value, WriteFlags flags = WriteFlags.None) { } + + public void Merge(ReadOnlySpan key, ReadOnlySpan value, WriteFlags flags = WriteFlags.None) { } + } } diff --git a/src/Nethermind/Nethermind.Network.Test/NodeFilterTests.cs b/src/Nethermind/Nethermind.Network.Test/NodeFilterTests.cs index 4f6063328703..a7320da252ad 100644 --- a/src/Nethermind/Nethermind.Network.Test/NodeFilterTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/NodeFilterTests.cs @@ -134,18 +134,6 @@ public void ThreadSafety_ConcurrentSetCallsSameAddress() Assert.That(acceptedCount, Is.LessThan(threadCount * attemptsPerThread), "not all concurrent attempts should be accepted for the same address"); } - [TestCase("192.0.2.1", "10.0.0.1", 0, 0, Description = "IPv4 /0 prefix - all match")] - [TestCase("192.0.2.1", "192.0.2.1", 32, 128, Description = "IPv4 /32 prefix - exact match")] - [TestCase("2001:db8::1", "fe80::1", 0, 0, Description = "IPv6 /0 prefix - all match")] - [TestCase("2001:db8::1", "2001:db8::1", 32, 128, Description = "IPv6 /128 prefix - exact match")] - public void SubnetMasking_EdgeCase_PrefixBoundary(string addr1, string addr2, byte v4Prefix, byte v6Prefix) - { - NodeFilter.IpSubnetKey key1 = new(IPAddress.Parse(addr1), v4PrefixBits: v4Prefix, v6PrefixBits: v6Prefix); - NodeFilter.IpSubnetKey key2 = new(IPAddress.Parse(addr2), v4PrefixBits: v4Prefix, v6PrefixBits: v6Prefix); - - Assert.That(key1, Is.EqualTo(key2)); - } - [Test] public void CapacityBounded_EvictsOldEntries() { @@ -181,40 +169,6 @@ public void Create_WhenDisabled_ReturnsAcceptAll() Assert.That(filter, Is.SameAs(NodeFilter.AcceptAll)); } - [TestCase("192.0.2.1", "192.0.2.1", true, Description = "Same address - equal")] - [TestCase("192.0.2.1", "192.0.2.2", false, Description = "Different address - not equal")] - public void IpSubnetKey_Equality_Exact(string addr1, string addr2, bool expectEqual) - { - NodeFilter.IpSubnetKey key1 = NodeFilter.IpSubnetKey.Exact(IPAddress.Parse(addr1)); - NodeFilter.IpSubnetKey key2 = NodeFilter.IpSubnetKey.Exact(IPAddress.Parse(addr2)); - - if (expectEqual) - { - Assert.That(key1, Is.EqualTo(key2)); - Assert.That(key1.GetHashCode(), Is.EqualTo(key2.GetHashCode())); - } - else - { - Assert.That(key1, Is.Not.EqualTo(key2)); - } - } - - [TestCase("192.0.2.1", "192.0.2.50", true, Description = "Same /24 subnet matches")] - [TestCase("192.0.2.1", "192.0.3.1", false, Description = "Different /24 subnet does not match")] - public void IpSubnetKey_Matches(string bucketAddr, string testAddr, bool expected) - { - NodeFilter.IpSubnetKey key = NodeFilter.IpSubnetKey.Bucket(IPAddress.Parse(bucketAddr), v4PrefixBits: 24, v6PrefixBits: 64); - Assert.That(key.Matches(IPAddress.Parse(testAddr)), Is.EqualTo(expected)); - } - - [TestCase("192.0.2.1", "192.0.2.50", true, Description = "Same /24 IPv4")] - [TestCase("192.0.2.1", "192.0.3.1", false, Description = "Different /24 IPv4")] - [TestCase("2001:db8::1", "2001:db8::ffff", true, Description = "Same /64 IPv6")] - [TestCase("2001:db8::1", "2001:db8:0:1::1", false, Description = "Different /64 IPv6")] - public void IpSubnetKey_AreInSameSubnet(string a, string b, bool expected) => Assert.That(NodeFilter.IpSubnetKey.AreInSameSubnet( - IPAddress.Parse(a), IPAddress.Parse(b), - v4PrefixBits: 24, v6PrefixBits: 64), Is.EqualTo(expected)); - [TestCase("127.0.0.1", true, Description = "IPv4 loopback")] [TestCase("::1", true, Description = "IPv6 loopback")] [TestCase("10.0.0.1", true, Description = "RFC1918 10.x")] @@ -227,20 +181,54 @@ public void IpSubnetKey_AreInSameSubnet(string a, string b, bool expected) => As [TestCase("fe80::1", true, Description = "IPv6 link-local")] [TestCase("8.8.8.8", false, Description = "Public IPv4")] [TestCase("2001:4860:4860::8888", false, Description = "Public IPv6")] - public void IpSubnetKey_IsLoopbackOrPrivateOrLinkLocal(string address, bool expected) => Assert.That(NodeFilter.IpSubnetKey.IsLoopbackOrPrivateOrLinkLocal(IPAddress.Parse(address)), Is.EqualTo(expected)); + public void IPAddressExtensions_IsLoopbackOrPrivateOrLinkLocal(string address, bool expected) => Assert.That(IPAddress.Parse(address).IsLoopbackOrPrivateOrLinkLocal, Is.EqualTo(expected)); + + [TestCase("0.1.2.3", true, Description = "IPv4 this-network")] + [TestCase("192.0.0.1", true, Description = "IPv4 IETF protocol assignments")] + [TestCase("192.0.2.1", true, Description = "IPv4 documentation TEST-NET-1")] + [TestCase("192.31.196.1", true, Description = "IPv4 AS112")] + [TestCase("192.52.193.1", true, Description = "IPv4 AMT")] + [TestCase("198.18.0.1", true, Description = "IPv4 benchmarking")] + [TestCase("192.175.48.1", true, Description = "IPv4 direct delegation AS112")] + [TestCase("198.51.100.1", true, Description = "IPv4 documentation TEST-NET-2")] + [TestCase("203.0.113.1", true, Description = "IPv4 documentation TEST-NET-3")] + [TestCase("224.0.0.1", true, Description = "IPv4 multicast")] + [TestCase("240.0.0.1", true, Description = "IPv4 reserved")] + [TestCase("::ffff:224.0.0.1", true, Description = "IPv4-mapped multicast")] + [TestCase("64:ff9b::1", true, Description = "IPv6 IPv4/IPv6 translation")] + [TestCase("64:ff9b:1::1", true, Description = "IPv6 local-use translation")] + [TestCase("100::1", true, Description = "IPv6 discard-only")] + [TestCase("2001::1", true, Description = "IPv6 IETF protocol assignments")] + [TestCase("2001:db8::1", true, Description = "IPv6 documentation")] + [TestCase("2002::1", true, Description = "IPv6 6to4")] + [TestCase("3fff::1", true, Description = "IPv6 documentation")] + [TestCase("8.8.8.8", false, Description = "Public IPv4")] + [TestCase("2001:4860:4860::8888", false, Description = "Public IPv6")] + public void IPAddressExtensions_IsSpecialUseAddress(string address, bool expected) => Assert.That(IPAddress.Parse(address).IsSpecialUseAddress, Is.EqualTo(expected)); + + [TestCase("224.0.0.1", true, Description = "IPv4 multicast")] + [TestCase("ff02::1", true, Description = "IPv6 multicast")] + [TestCase("8.8.8.8", false, Description = "Public IPv4")] + [TestCase("2001:4860:4860::8888", false, Description = "Public IPv6")] + public void IPAddressExtensions_IsMulticast(string address, bool expected) => Assert.That(IPAddress.Parse(address).IsMulticast, Is.EqualTo(expected)); [TestCase("192.168.1.10", "192.168.1.20", "203.0.113.1", false, Description = "Private addresses use exact keying")] [TestCase("203.0.113.1", "203.0.113.50", "198.51.100.1", true, Description = "Public addresses in same /24 use subnet bucketing")] [TestCase("192.168.1.10", "192.168.1.20", "192.168.1.1", false, Description = "Same local subnet uses exact keying")] - public void CreateNodeFilterKey_KeyingBehavior(string remote1, string remote2, string current, bool expectEqual) + public void TryAccept_UsesExpectedKeyingBehavior(string remote1, string remote2, string current, bool expectEqual) { - NodeFilter.IpSubnetKey key1 = NodeFilter.IpSubnetKey.CreateNodeFilterKey(IPAddress.Parse(remote1), IPAddress.Parse(current)); - NodeFilter.IpSubnetKey key2 = NodeFilter.IpSubnetKey.CreateNodeFilterKey(IPAddress.Parse(remote2), IPAddress.Parse(current)); + NodeFilter filter = CreateFilter(currentIp: IPAddress.Parse(current)); if (expectEqual) - Assert.That(key1, Is.EqualTo(key2)); + { + Assert.That(filter.TryAccept(IPAddress.Parse(remote1)), Is.True); + Assert.That(filter.TryAccept(IPAddress.Parse(remote2)), Is.False); + } else - Assert.That(key1, Is.Not.EqualTo(key2)); + { + Assert.That(filter.TryAccept(IPAddress.Parse(remote1)), Is.True); + Assert.That(filter.TryAccept(IPAddress.Parse(remote2)), Is.True); + } } [TestCase(true, "192.0.2.1", "192.0.2.1", Description = "Exact match reaccepts after timeout")] diff --git a/src/Nethermind/Nethermind.Network.Test/NodesLoaderTests.cs b/src/Nethermind/Nethermind.Network.Test/NodesLoaderTests.cs index aca6cbdf93a1..55c6ba035153 100644 --- a/src/Nethermind/Nethermind.Network.Test/NodesLoaderTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/NodesLoaderTests.cs @@ -9,7 +9,6 @@ using Nethermind.Core.Test.Builders; using Nethermind.Logging; using Nethermind.Network.Config; -using Nethermind.Network.Discovery; using Nethermind.Stats; using Nethermind.Stats.Model; using NSubstitute; @@ -21,21 +20,25 @@ namespace Nethermind.Network.Test; public class NodesLoaderTests { private NetworkConfig _networkConfig; - private DiscoveryConfig _discoveryConfig; private INodeStatsManager _statsManager; private INetworkStorage _peerStorage; + private IEnode _enode; private NodesLoader _loader; [SetUp] public void SetUp() { _networkConfig = new NetworkConfig(); - _discoveryConfig = new DiscoveryConfig(); _statsManager = Substitute.For(); _peerStorage = Substitute.For(); - _loader = new NodesLoader(_networkConfig, _statsManager, _peerStorage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance); + _enode = new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303); + _loader = CreateLoader(); } + private NodesLoader CreateLoader(bool loadBootnodesAsPeerCandidates = true) => + new(_networkConfig, _statsManager, _peerStorage, _enode, LimboLogs.Instance, + new NodesLoaderOptions(LoadBootnodesAsPeerCandidates: loadBootnodesAsPeerCandidates)); + [Test] public void When_no_peers_then_no_peers_nada_zero() { @@ -62,8 +65,7 @@ public void Can_load_static_nodes() [Test] public void Can_load_bootnodes() { - _discoveryConfig.Bootnodes = new[] { new NetworkNode(enode1String), new NetworkNode(enode2String) }; - _networkConfig.Bootnodes = _discoveryConfig.Bootnodes; + _networkConfig.Bootnodes = new[] { new NetworkNode(enode1String), new NetworkNode(enode2String) }; List nodes = _loader.DiscoverNodes(default).ToBlockingEnumerable().ToList(); Assert.That(nodes.Count, Is.EqualTo(2)); foreach (Node node in nodes) @@ -72,6 +74,17 @@ public void Can_load_bootnodes() } } + [Test] + public void Does_not_load_bootnodes_as_peer_candidates_when_only_discv5_is_enabled() + { + _networkConfig.Bootnodes = new[] { new NetworkNode(enode1String), new NetworkNode(enode2String) }; + _loader = CreateLoader(loadBootnodesAsPeerCandidates: false); + + List nodes = _loader.DiscoverNodes(default).ToBlockingEnumerable().ToList(); + + Assert.That(nodes, Is.Empty); + } + [Test] public void Can_load_persisted() { diff --git a/src/Nethermind/Nethermind.Network.Test/PeerManagerFilteringIntegrationTests.cs b/src/Nethermind/Nethermind.Network.Test/PeerManagerFilteringIntegrationTests.cs index ccfbc6457243..621ebc7162b2 100644 --- a/src/Nethermind/Nethermind.Network.Test/PeerManagerFilteringIntegrationTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/PeerManagerFilteringIntegrationTests.cs @@ -44,7 +44,7 @@ public async Task PeerManager_CallsShouldContactBeforeConnectAsync() MaxOutgoingConnectPerSec = 1000000 }; - NodesLoader nodesLoader = new(networkConfig, stats, storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance); + NodesLoader nodesLoader = new(networkConfig, stats, storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance, new NodesLoaderOptions()); IStaticNodesManager staticNodesManager = Substitute.For(); staticNodesManager.DiscoverNodes(Arg.Any()).Returns(AsyncEnumerable.Empty()); TestNodeSource testNodeSource = new(); @@ -153,7 +153,7 @@ public Context() MaxOutgoingConnectPerSec = 1000000 }; - NodesLoader nodesLoader = new(networkConfig, stats, storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance); + NodesLoader nodesLoader = new(networkConfig, stats, storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance, new NodesLoaderOptions()); StaticNodesManager = Substitute.For(); StaticNodesManager.DiscoverNodes(Arg.Any()).Returns(AsyncEnumerable.Empty()); TestNodeSource = new TestNodeSource(); diff --git a/src/Nethermind/Nethermind.Network.Test/PeerManagerTests.cs b/src/Nethermind/Nethermind.Network.Test/PeerManagerTests.cs index a2282a4abddd..c0037ab8f424 100644 --- a/src/Nethermind/Nethermind.Network.Test/PeerManagerTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/PeerManagerTests.cs @@ -726,7 +726,7 @@ public Context(int parallelism = 0, int maxActivePeers = 25) ITimerFactory timerFactory = Substitute.For(); Stats = new NodeStatsManager(timerFactory, LimboLogs.Instance); Storage = new InMemoryStorage(); - NodesLoader = new NodesLoader(new NetworkConfig(), Stats, Storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance); + NodesLoader = new NodesLoader(new NetworkConfig(), Stats, Storage, new Enode(TestItem.PublicKeyA, IPAddress.Loopback, 30303), LimboLogs.Instance, new NodesLoaderOptions()); NetworkConfig = new NetworkConfig(); NetworkConfig.MaxActivePeers = maxActivePeers; NetworkConfig.PeersPersistenceInterval = 50; diff --git a/src/Nethermind/Nethermind.Network.Test/Stats/NodeTests.cs b/src/Nethermind/Nethermind.Network.Test/Stats/NodeTests.cs index 327bfb2ddef6..fd2f5b79f9f4 100644 --- a/src/Nethermind/Nethermind.Network.Test/Stats/NodeTests.cs +++ b/src/Nethermind/Nethermind.Network.Test/Stats/NodeTests.cs @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; +using System.Net; using Nethermind.Core.Test.Builders; +using Nethermind.Crypto; +using Nethermind.Network.Enr; using Nethermind.Stats.Model; using NUnit.Framework; @@ -17,6 +21,15 @@ public void Can_parse_ipv6_prefixed_ip() Node node = new(TestItem.PublicKeyA, "::ffff:73.224.122.50", 65535); Assert.That(node.Port, Is.EqualTo(65535)); Assert.That(node.Address.Address.MapToIPv4().ToString(), Is.EqualTo("73.224.122.50")); + Assert.That(node.Host, Is.EqualTo("73.224.122.50")); + } + + [Test] + public void Can_parse_native_ipv6_ip() + { + Node node = new(TestItem.PublicKeyA, "2001:4860:4860::8888", 65535); + Assert.That(node.Port, Is.EqualTo(65535)); + Assert.That(node.Host, Is.EqualTo("2001:4860:4860::8888")); } [Test] @@ -27,6 +40,35 @@ public void Not_equal_to_another_type() Assert.That(node.Equals(1), Is.False); } + [TestCase(NodeFromEnrMode.PeerCandidate, 30303)] + [TestCase(NodeFromEnrMode.Discovery, 30304)] + public void TryFromEnr_uses_expected_endpoint(NodeFromEnrMode mode, int expectedPort) + { + NodeRecord enr = CreateEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), tcpPort: 30303, udpPort: 30304); + + bool result = TryCreateNodeFromEnr(mode, enr, out Node? node); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.True); + Assert.That(node, Is.Not.Null); + Assert.That(node!.Host, Is.EqualTo("8.8.8.8")); + Assert.That(node.Port, Is.EqualTo(expectedPort)); + Assert.That(node.Enr, Is.EqualTo(enr.EnrString)); + } + } + + [Test] + public void TryFromEnr_rejects_udp_only_record() + { + NodeRecord enr = CreateEnr(TestItem.PrivateKeyA, IPAddress.Parse("8.8.8.8"), tcpPort: null, udpPort: 30304); + + bool result = Node.TryFromEnr(enr, out Node? node); + + Assert.That(result, Is.False); + Assert.That(node, Is.Null); + } + [TestCase("s", "127.0.0.1:303")] [TestCase("a", " 127.0.0.1: 303")] [TestCase("c", "[Node|127.0.0.1:303|Details|ClientId]")] @@ -46,6 +88,37 @@ static Node GetNode(string host) => Assert.That(node.ToString(format), Is.EqualTo(expectedFormat)); } + private static NodeRecord CreateEnr(PrivateKey privateKey, IPAddress ipAddress, int? tcpPort, int? udpPort) + { + NodeRecord enr = new(); + enr.SetEntry(IdEntry.Instance); + enr.SetEntry(new IpEntry(ipAddress)); + enr.SetEntry(new SecP256k1Entry(privateKey.CompressedPublicKey)); + if (tcpPort is not null) + { + enr.SetEntry(new TcpEntry(tcpPort.Value)); + } + if (udpPort is not null) + { + enr.SetEntry(new UdpEntry(udpPort.Value)); + } + enr.EnrSequence = 1; + new NodeRecordSigner(new EthereumEcdsa(0), privateKey).Sign(enr); + return enr; + } + + private static bool TryCreateNodeFromEnr(NodeFromEnrMode mode, NodeRecord enr, out Node? node) => + mode switch + { + NodeFromEnrMode.PeerCandidate => Node.TryFromEnr(enr, out node), + NodeFromEnrMode.Discovery => Node.TryFromDiscoveryEnr(enr, out node), + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null) + }; + public enum NodeFromEnrMode + { + PeerCandidate, + Discovery + } } } diff --git a/src/Nethermind/Nethermind.Network/CompositeNodeSource.cs b/src/Nethermind/Nethermind.Network/CompositeNodeSource.cs index ac9ad7e2c0a4..c5fe1070fde7 100644 --- a/src/Nethermind/Nethermind.Network/CompositeNodeSource.cs +++ b/src/Nethermind/Nethermind.Network/CompositeNodeSource.cs @@ -20,13 +20,15 @@ public class CompositeNodeSource : INodeSource, IDisposable public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] CancellationToken cancellationToken) { + using CancellationTokenSource disposeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + CancellationToken feedToken = disposeCts.Token; Channel ch = Channel.CreateBounded(1); using ArrayPoolList feedTasks = _nodeSources.Select(async innerSource => { - await foreach (Node node in innerSource.DiscoverNodes(cancellationToken)) + await foreach (Node node in innerSource.DiscoverNodes(feedToken)) { - await ch.Writer.WriteAsync(node, cancellationToken); + await ch.Writer.WriteAsync(node, feedToken); } }).ToPooledList(_nodeSources.Length * 16); @@ -39,7 +41,15 @@ public async IAsyncEnumerable DiscoverNodes([EnumeratorCancellation] Cance } finally { - await Task.WhenAll(feedTasks.AsSpan()); + await disposeCts.CancelAsync(); + ch.Writer.TryComplete(); + try + { + await Task.WhenAll(feedTasks.AsSpan()); + } + catch (OperationCanceledException) when (feedToken.IsCancellationRequested) + { + } } } diff --git a/src/Nethermind/Nethermind.Network/Discovery/Messages/MsgType.cs b/src/Nethermind/Nethermind.Network/Discovery/Discv4/Messages/MsgType.cs similarity index 80% rename from src/Nethermind/Nethermind.Network/Discovery/Messages/MsgType.cs rename to src/Nethermind/Nethermind.Network/Discovery/Discv4/Messages/MsgType.cs index 41ac1fc7532c..5c1c62c31408 100644 --- a/src/Nethermind/Nethermind.Network/Discovery/Messages/MsgType.cs +++ b/src/Nethermind/Nethermind.Network/Discovery/Discv4/Messages/MsgType.cs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only -namespace Nethermind.Network.Discovery.Messages; +namespace Nethermind.Network.Discovery.Discv4.Messages; public enum MsgType { diff --git a/src/Nethermind/Nethermind.Network/IP/IPAddressExtensions.cs b/src/Nethermind/Nethermind.Network/IP/IPAddressExtensions.cs deleted file mode 100644 index a05e4a1dbfd4..000000000000 --- a/src/Nethermind/Nethermind.Network/IP/IPAddressExtensions.cs +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited -// SPDX-License-Identifier: LGPL-3.0-only - -using System.Net; - -namespace Nethermind.Network.IP -{ - public static class IPAddressExtensions - { - /// - /// An extension method to determine if an IP address is internal, as specified in RFC1918 - /// - /// The IP address that will be tested - /// Returns true if the IP is internal, false if it is external - public static bool IsInternal(this IPAddress toTest) - { - byte[] bytes = toTest.GetAddressBytes(); - return bytes[0] switch - { - 10 => true, - 172 => bytes[1] < 32 && bytes[1] >= 16, - 192 => bytes[1] == 168, - _ => false, - }; - } - } -} diff --git a/src/Nethermind/Nethermind.Network/IP/WebIPSource.cs b/src/Nethermind/Nethermind.Network/IP/WebIPSource.cs index 0ac1a0d4205a..ad7bb2cc6a54 100644 --- a/src/Nethermind/Nethermind.Network/IP/WebIPSource.cs +++ b/src/Nethermind/Nethermind.Network/IP/WebIPSource.cs @@ -14,21 +14,22 @@ class WebIPSource(string url, ILogManager logManager) : IIPSource private readonly string _url = url; private readonly ILogger _logger = logManager.GetClassLogger(); - public Task<(bool, IPAddress)> TryGetIP() + public async Task<(bool, IPAddress)> TryGetIP() { try { using HttpClient httpClient = new() { Timeout = TimeSpan.FromSeconds(3) }; if (_logger.IsInfo) _logger.Info($"Using {_url} to get external ip"); - string ip = httpClient.GetStringAsync(_url).Result.Trim(); + string ip = (await httpClient.GetStringAsync(_url)).Trim(); if (_logger.IsDebug) _logger.Debug($"External ip: {ip}"); bool result = IPAddress.TryParse(ip, out IPAddress ipAddress); - return Task.FromResult(result && !ipAddress.IsInternal() ? (true, ipAddress) : (false, (IPAddress)null)); + bool isExternal = result && !ipAddress.IsLoopbackOrPrivateOrLinkLocal; + return isExternal ? (true, ipAddress) : (false, (IPAddress)null); } catch (Exception e) { _logger.DebugError($"Error while getting external ip from {_url}", e); - return Task.FromResult((false, (IPAddress)null)); + return (false, (IPAddress)null); } } } diff --git a/src/Nethermind/Nethermind.Network/IPAddressExtensions.cs b/src/Nethermind/Nethermind.Network/IPAddressExtensions.cs new file mode 100644 index 000000000000..a9b8e09f3016 --- /dev/null +++ b/src/Nethermind/Nethermind.Network/IPAddressExtensions.cs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Net; + +namespace Nethermind.Network; + +/// +/// IP address classification helpers used by peer and discovery filtering. +/// +public static class IPAddressExtensions +{ + extension(IPAddress ipAddress) + { + /// + /// Returns true for loopback, private, link-local, CGNAT, and IPv6 ULA addresses. + /// + public bool IsLoopbackOrPrivateOrLinkLocal + => ParsedIPAddress.Parse(ipAddress).IsLoopbackOrPrivateOrLinkLocal; + + /// + /// Returns true for IPv4 or IPv6 multicast addresses. + /// + public bool IsMulticast + => ParsedIPAddress.Parse(ipAddress).IsMulticast; + + /// + /// Returns true for IPv4 multicast addresses. + /// + public bool IsIPv4Multicast + => ParsedIPAddress.Parse(ipAddress).IsIPv4Multicast; + + /// + /// Returns true for special-use addresses that should not be accepted as routable peers. + /// + /// + /// This intentionally does not include loopback, private, link-local, CGNAT, or IPv6 ULA ranges; + /// callers that support private deployments can decide whether to accept those separately. + /// + public bool IsSpecialUseAddress + => ParsedIPAddress.Parse(ipAddress).IsSpecialUseAddress; + } +} diff --git a/src/Nethermind/Nethermind.Network/Metrics.cs b/src/Nethermind/Nethermind.Network/Metrics.cs index 6cd555d27095..9208aa732312 100644 --- a/src/Nethermind/Nethermind.Network/Metrics.cs +++ b/src/Nethermind/Nethermind.Network/Metrics.cs @@ -5,7 +5,7 @@ using System.ComponentModel; using System.Runtime.Serialization; using Nethermind.Core.Attributes; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.P2P; using Nethermind.Stats.Model; diff --git a/src/Nethermind/Nethermind.Network/NetworkNodeDecoder.cs b/src/Nethermind/Nethermind.Network/NetworkNodeDecoder.cs index d37f5f474956..ad9bb2fd7db8 100644 --- a/src/Nethermind/Nethermind.Network/NetworkNodeDecoder.cs +++ b/src/Nethermind/Nethermind.Network/NetworkNodeDecoder.cs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited // SPDX-License-Identifier: LGPL-3.0-only +using System; +using System.Text; using Nethermind.Config; using Nethermind.Core.Crypto; using Nethermind.Core.Extensions; @@ -10,15 +12,33 @@ namespace Nethermind.Network { public sealed class NetworkNodeDecoder : RlpDecoder { - private static readonly RlpLimit RlpLimit = RlpLimit.For((int)1.KiB, nameof(NetworkNode.HostIp)); + public static NetworkNodeDecoder Instance { get; } = new(); - static NetworkNodeDecoder() => Rlp.RegisterDecoder(typeof(NetworkNode), new NetworkNodeDecoder()); + private static readonly RlpLimit RlpLimit = RlpLimit.For((int)1.KiB, nameof(NetworkNode.HostIp)); protected override NetworkNode DecodeInternal(ref Rlp.ValueDecoderContext decoderContext, RlpBehaviors rlpBehaviors = RlpBehaviors.None) { - decoderContext.ReadSequenceLength(); + int contentEnd = decoderContext.ReadSequenceLength() + decoderContext.Position; + ReadOnlySpan firstItem = decoderContext.DecodeByteArraySpan(RlpLimit); + return IsEnrString(firstItem) + ? DecodeEnrFormat(ref decoderContext, firstItem, contentEnd) + : DecodeLegacyFormat(ref decoderContext, firstItem); + } + + private static NetworkNode DecodeEnrFormat(ref Rlp.ValueDecoderContext decoderContext, ReadOnlySpan firstItem, int contentEnd) + { + string nodeString = Encoding.UTF8.GetString(firstItem); + long reputation = decoderContext.DecodeLong(); + decoderContext.Check(contentEnd); + return new NetworkNode(nodeString) + { + Reputation = reputation + }; + } - PublicKey publicKey = new(decoderContext.DecodeByteArraySpan(RlpLimit.L64)); + private static NetworkNode DecodeLegacyFormat(ref Rlp.ValueDecoderContext decoderContext, ReadOnlySpan publicKeyBytes) + { + PublicKey publicKey = new(publicKeyBytes); string ip = decoderContext.DecodeString(RlpLimit); int port = (int)decoderContext.DecodeByteArraySpan(RlpLimit.L8).ReadEthUInt64(); decoderContext.SkipItem(); @@ -40,6 +60,20 @@ public override void Encode(RlpStream stream, NetworkNode item, RlpBehaviors rlp { int contentLength = GetContentLength(item, rlpBehaviors); stream.StartSequence(contentLength); + if (!item.IsEnr) + { + EncodeLegacyFormat(stream, item); + return; + } + + stream.Encode(item.ToString()); + stream.Encode(item.Reputation); + } + + public override int GetLength(NetworkNode item, RlpBehaviors rlpBehaviors) => Rlp.LengthOfSequence(GetContentLength(item, rlpBehaviors)); + + private static void EncodeLegacyFormat(RlpStream stream, NetworkNode item) + { stream.Encode(item.NodeId.Bytes); stream.Encode(item.Host); stream.Encode(item.Port); @@ -47,17 +81,18 @@ public override void Encode(RlpStream stream, NetworkNode item, RlpBehaviors rlp stream.Encode(item.Reputation); } - public override int GetLength(NetworkNode item, RlpBehaviors rlpBehaviors) => Rlp.LengthOfSequence(GetContentLength(item, rlpBehaviors)); + private static int GetContentLength(NetworkNode item, RlpBehaviors rlpBehaviors) => item.IsEnr + ? Rlp.LengthOf(item.ToString()) + + Rlp.LengthOf(item.Reputation) + : Rlp.LengthOf(item.NodeId.Bytes) + + Rlp.LengthOf(item.Host) + + Rlp.LengthOf(item.Port) + + 1 + + Rlp.LengthOf(item.Reputation); - private static int GetContentLength(NetworkNode item, RlpBehaviors rlpBehaviors) => Rlp.LengthOf(item.NodeId.Bytes) - + Rlp.LengthOf(item.Host) - + Rlp.LengthOf(item.Port) - + 1 - + Rlp.LengthOf(item.Reputation); + private static bool IsEnrString(ReadOnlySpan value) => + value.Length != PublicKey.LengthInBytes && + value is [(byte)'e', (byte)'n', (byte)'r', (byte)':', ..]; - public static void Init() - { - // here to register with RLP in static constructor - } } } diff --git a/src/Nethermind/Nethermind.Network/NetworkStorage.cs b/src/Nethermind/Nethermind.Network/NetworkStorage.cs index cf59e1a913b1..54791b616252 100644 --- a/src/Nethermind/Nethermind.Network/NetworkStorage.cs +++ b/src/Nethermind/Nethermind.Network/NetworkStorage.cs @@ -11,12 +11,13 @@ using Nethermind.Core.Extensions; using Nethermind.Db; using Nethermind.Logging; -using Nethermind.Serialization.Rlp; namespace Nethermind.Network { public class NetworkStorage(IFullDb? fullDb, ILogManager? logManager) : INetworkStorage { + private static readonly NetworkNodeDecoder NodeDecoder = NetworkNodeDecoder.Instance; + private readonly Lock _lock = new(); private readonly IFullDb _fullDb = fullDb ?? throw new ArgumentNullException(nameof(fullDb)); private readonly ILogger _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); @@ -24,6 +25,7 @@ public class NetworkStorage(IFullDb? fullDb, ILogManager? logManager) : INetwork private long _updateCounter; private long _removeCounter; private NetworkNode[]? _nodes; + private bool _loadedFromDb; public int PersistedNodesCount => GetPersistedNodes().Length; @@ -44,10 +46,7 @@ private NetworkNode[] GenerateNodes() return nodes; } - if (_nodesDict.Count == 0) - { - LoadFromDb(); - } + EnsureLoadedFromDbNoLock(); return _nodesDict.Count == 0 ? [] : CopyDictToArray(); } @@ -60,7 +59,16 @@ private NetworkNode[] CopyDictToArray() return (_nodes = nodes); } - private void LoadFromDb() + private void EnsureLoadedFromDbNoLock() + { + if (!_loadedFromDb) + { + LoadFromDbNoLock(); + _loadedFromDb = true; + } + } + + private void LoadFromDbNoLock() { foreach (byte[]? nodeRlp in _fullDb.Values) { @@ -72,7 +80,7 @@ private void LoadFromDb() try { NetworkNode node = GetNode(nodeRlp); - _nodesDict[node.NodeId] = node; + _nodesDict.TryAdd(node.NodeId, node); } catch (Exception e) { @@ -85,13 +93,16 @@ public void UpdateNode(NetworkNode node) { lock (_lock) { - UpdateNodeImpl(node); + byte[] rlp = NodeDecoder.Encode(node).Bytes; + UpdateNodeImpl(node, rlp); } } - private void UpdateNodeImpl(NetworkNode node) + private void UpdateNodeImpl(NetworkNode node, byte[] rlp) { - (_currentBatch ?? (IWriteOnlyKeyValueStore)_fullDb)[node.NodeId.Bytes] = Rlp.Encode(node).Bytes; + EnsureLoadedFromDbNoLock(); + + (_currentBatch ?? (IWriteOnlyKeyValueStore)_fullDb)[node.NodeId.Bytes] = rlp; _updateCounter++; if (!_nodesDict.ContainsKey(node.NodeId)) @@ -108,27 +119,31 @@ private void UpdateNodeImpl(NetworkNode node) public void UpdateNodes(IEnumerable nodes) { + List<(NetworkNode Node, byte[] Rlp)> encodedNodes = []; + foreach (NetworkNode node in nodes) + { + encodedNodes.Add((node, NodeDecoder.Encode(node).Bytes)); + } + lock (_lock) { - foreach (NetworkNode node in nodes) + for (int i = 0; i < encodedNodes.Count; i++) { - UpdateNodeImpl(node); + (NetworkNode node, byte[] rlp) = encodedNodes[i]; + UpdateNodeImpl(node, rlp); } } } public void RemoveNode(PublicKey nodeId) - { - (_currentBatch ?? (IWriteOnlyKeyValueStore)_fullDb)[nodeId.Bytes] = null; - _removeCounter++; - - RemoveLocal(nodeId); - } - - private void RemoveLocal(PublicKey nodeId) { lock (_lock) { + EnsureLoadedFromDbNoLock(); + + (_currentBatch ?? (IWriteOnlyKeyValueStore)_fullDb)[nodeId.Bytes] = null; + _removeCounter++; + if (_nodesDict.Remove(nodeId)) { // Clear the cache @@ -141,26 +156,76 @@ private void RemoveLocal(PublicKey nodeId) public void StartBatch() { - _currentBatch = _fullDb.StartWriteBatch(); - _updateCounter = 0; - _removeCounter = 0; + lock (_lock) + { + DiscardBatchNoLock(); + _currentBatch = _fullDb.StartWriteBatch(); + } } public void Commit() { - if (_logger.IsTrace) _logger.Trace($"[{_fullDb.Name}] Committing nodes, updates: {_updateCounter}, removes: {_removeCounter}"); - _currentBatch?.Dispose(); + IWriteBatch? currentBatch; + lock (_lock) + { + if (_logger.IsTrace) _logger.Trace($"[{_fullDb.Name}] Committing nodes, updates: {_updateCounter}, removes: {_removeCounter}"); + currentBatch = _currentBatch; + _currentBatch = null; + _updateCounter = 0; + _removeCounter = 0; + } + + try + { + currentBatch?.Dispose(); + } + catch + { + ClearLocalCache(); + throw; + } + if (_logger.IsTrace) { LogDbContent(_fullDb.Values); } } + private void DiscardBatchNoLock() + { + IWriteBatch? currentBatch = _currentBatch; + _currentBatch = null; + _updateCounter = 0; + _removeCounter = 0; + + if (currentBatch is not null) + { + currentBatch.Clear(); + currentBatch.Dispose(); + ClearLocalCacheNoLock(); + } + } + + private void ClearLocalCache() + { + lock (_lock) + { + ClearLocalCacheNoLock(); + } + } + + private void ClearLocalCacheNoLock() + { + _nodesDict.Clear(); + _nodes = null; + _loadedFromDb = false; + } + public bool AnyPendingChange() => _updateCounter > 0 || _removeCounter > 0; private static NetworkNode GetNode(byte[] networkNodeRaw) { - NetworkNode persistedNode = Rlp.Decode(networkNodeRaw); + NetworkNode persistedNode = NodeDecoder.DecodeComplete(networkNodeRaw); return persistedNode; } diff --git a/src/Nethermind/Nethermind.Network/NodeFilter.cs b/src/Nethermind/Nethermind.Network/NodeFilter.cs index f9d69c1ef0d3..1dfd6cce8bdd 100644 --- a/src/Nethermind/Nethermind.Network/NodeFilter.cs +++ b/src/Nethermind/Nethermind.Network/NodeFilter.cs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: LGPL-3.0-only using System; -using System.Buffers.Binary; using System.Net; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -26,7 +25,7 @@ public sealed class NodeFilter private readonly ClockCache? _cache; private readonly bool _exactMatchOnly; - private readonly IpSubnetKey.ParsedIp? _parsedCurrentIp; + private readonly ParsedIPAddress? _parsedCurrentIp; private readonly long _timeoutMs; private NodeFilter() { } @@ -38,7 +37,7 @@ internal NodeFilter(int size, bool exactMatchOnly, IPAddress? currentIp, long ti { _cache = new(size); _exactMatchOnly = exactMatchOnly; - _parsedCurrentIp = currentIp is not null ? new IpSubnetKey.ParsedIp(currentIp) : null; + _parsedCurrentIp = currentIp is not null ? ParsedIPAddress.Parse(currentIp) : null; _timeoutMs = timeoutMs; } @@ -54,15 +53,6 @@ public static NodeFilter Create(int maxActivePeers, bool filterEnabled, bool sub public static NodeFilter CreateExact(int size, TimeSpan timeout) => new(size, exactMatchOnly: true, currentIp: null, (long)timeout.TotalMilliseconds); - public static bool IsLoopbackOrPrivateOrLinkLocal(IPAddress ipAddress) - => IpSubnetKey.IsLoopbackOrPrivateOrLinkLocal(ipAddress); - - public static bool IsIPv4Multicast(IPAddress ipAddress) - { - byte[] bytes = ipAddress.GetAddressBytes(); - return bytes.Length == 4 && bytes[0] is >= 224 and <= 239; - } - /// /// Checks whether should be accepted. /// Returns true if the address was not seen recently, false if it was. @@ -83,18 +73,6 @@ public bool TryAccept(IPAddress ipAddress, bool exactOnly = false) return true; } - /// - /// Read-only check: returns true if the address would be accepted (not seen recently), - /// without inserting it into the cache. - /// - public bool CanAccept(IPAddress ipAddress, bool exactOnly = false) - { - if (_cache is null) return true; - - IpSubnetKey key = GetKey(ipAddress, exactOnly); - return !_cache.TryGet(key, out long lastSeen) || Environment.TickCount64 - lastSeen >= _timeoutMs; - } - public void Touch(IPAddress ipAddress, bool exactOnly = false) { if (_cache is null) @@ -110,35 +88,15 @@ private IpSubnetKey GetKey(IPAddress ipAddress, bool exactOnly) => _exactMatchOnly || exactOnly ? IpSubnetKey.Exact(ipAddress) : (_parsedCurrentIp is { } current - ? IpSubnetKey.CreateNodeFilterKey(ipAddress, current) + ? IpSubnetKey.CreateNodeFilterKey(ipAddress, in current) : IpSubnetKey.DefaultKey(ipAddress)); /// /// Allocation-free key for an IP address or a masked subnet prefix, suitable for hash lookups and prefix checks. /// [StructLayout(LayoutKind.Explicit)] - internal readonly struct IpSubnetKey : IEquatable + private readonly struct IpSubnetKey : IEquatable { - internal enum IpFamily : byte { IPv4 = 4, IPv6 = 6 } - - internal readonly struct ParsedIp - { - public readonly IpFamily Family; - public readonly uint V4; - public readonly ulong Hi; - public readonly ulong Lo; - public readonly bool IsLocal; - - public ParsedIp(IPAddress ip) - { - Family = ReadAddress(ip, out uint v4, out ulong hi, out ulong lo); - V4 = v4; - Hi = hi; - Lo = lo; - IsLocal = IsLoopbackOrPrivateOrLinkLocal(Family, v4, hi, lo); - } - } - // For IPv6: _hi/_lo are the masked 128-bit network prefix (big-endian). // For IPv4: _hi holds the masked v4 in the low 32 bits (big-endian), _lo is 0. [FieldOffset(0)] @@ -151,44 +109,23 @@ public ParsedIp(IPAddress ip) public static IpSubnetKey DefaultKey(IPAddress ipAddress, byte v4BucketPrefixBits = 24, byte v6BucketPrefixBits = 64) { - IpFamily family = ReadAddress(ipAddress, out uint v4, out ulong hi, out ulong lo); + ParsedIPAddress parsed = ParsedIPAddress.Parse(ipAddress); - if (IsLoopbackOrPrivateOrLinkLocal(family, v4, hi, lo)) + if (parsed.IsLoopbackOrPrivateOrLinkLocal) { v4BucketPrefixBits = 32; v6BucketPrefixBits = 128; } - return family == IpFamily.IPv4 - ? CreateFromV4(v4, v4BucketPrefixBits) - : CreateFromV6(hi, lo, v6BucketPrefixBits); - } - - public IpSubnetKey(IPAddress ipAddress, byte v4PrefixBits = 24, byte v6PrefixBits = 64) - { - IpFamily family = ReadAddress(ipAddress, out uint v4, out ulong hi, out ulong lo); - - if (family == IpFamily.IPv4) - { - _meta = MakeMeta(IpFamily.IPv4, v4PrefixBits); - _hi = MaskV4(v4, v4PrefixBits); - _lo = 0; - return; - } - - _meta = MakeMeta(IpFamily.IPv6, v6PrefixBits); - MaskV6(ref hi, ref lo, v6PrefixBits); - _hi = hi; - _lo = lo; + return CreateFromParsed(in parsed, v4BucketPrefixBits, v6BucketPrefixBits); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IpSubnetKey Exact(IPAddress ipAddress) - => new(ipAddress, v4PrefixBits: 32, v6PrefixBits: 128); - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IpSubnetKey Bucket(IPAddress ipAddress, byte v4PrefixBits = 24, byte v6PrefixBits = 64) - => new(ipAddress, v4PrefixBits, v6PrefixBits); + { + ParsedIPAddress parsed = ParsedIPAddress.Parse(ipAddress); + return CreateExactFromParsed(in parsed); + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Equals(IpSubnetKey other) @@ -206,41 +143,9 @@ public override bool Equals(object? obj) public override int GetHashCode() => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in _hi)), 2 * sizeof(ulong) + sizeof(ushort)).FastHash(); - public bool Matches(IPAddress other) - { - IpFamily family = (IpFamily)(_meta >> 8); - byte prefix = (byte)_meta; - - IpFamily otherFamily = ReadAddress(other, out uint v4, out ulong hi, out ulong lo); - if (otherFamily != family) - return false; - - if (family == IpFamily.IPv4) - return _hi == MaskV4Trusted(v4, prefix); - - MaskV6Trusted(ref hi, ref lo, prefix); - return _hi == hi && _lo == lo; - } - - public static bool AreInSameSubnet(IPAddress a, IPAddress b, byte v4PrefixBits = 24, byte v6PrefixBits = 64) - { - IpFamily fa = ReadAddress(a, out uint a4, out ulong aHi, out ulong aLo); - IpFamily fb = ReadAddress(b, out uint b4, out ulong bHi, out ulong bLo); - - if (fa != fb) - return false; - - if (fa == IpFamily.IPv4) - return MaskV4(a4, v4PrefixBits) == MaskV4(b4, v4PrefixBits); - - MaskV6(ref aHi, ref aLo, v6PrefixBits); - MaskV6(ref bHi, ref bLo, v6PrefixBits); - return aHi == bHi && aLo == bLo; - } - public static IpSubnetKey CreateNodeFilterKey( IPAddress remoteIp, - IPAddress currentIp, + in ParsedIPAddress currentIp, byte v4BucketPrefixBits = 24, byte v6BucketPrefixBits = 64, byte v4LocalPrefixBits = 24, @@ -248,67 +153,48 @@ public static IpSubnetKey CreateNodeFilterKey( bool exactIfSameSubnetAsCurrentIp = true, bool requireCurrentIpIsLocalForExact = true) { - ParsedIp current = new(currentIp); - return CreateNodeFilterKey(remoteIp, current, - v4BucketPrefixBits, v6BucketPrefixBits, - v4LocalPrefixBits, v6LocalPrefixBits, - exactIfSameSubnetAsCurrentIp, requireCurrentIpIsLocalForExact); - } + ParsedIPAddress remote = ParsedIPAddress.Parse(remoteIp); - public static IpSubnetKey CreateNodeFilterKey( - IPAddress remoteIp, - ParsedIp currentIp, - byte v4BucketPrefixBits = 24, - byte v6BucketPrefixBits = 64, - byte v4LocalPrefixBits = 24, - byte v6LocalPrefixBits = 64, - bool exactIfSameSubnetAsCurrentIp = true, - bool requireCurrentIpIsLocalForExact = true) - { - IpFamily rFamily = ReadAddress(remoteIp, out uint rV4, out ulong rHi, out ulong rLo); - - if (IsLoopbackOrPrivateOrLinkLocal(rFamily, rV4, rHi, rLo)) - return CreateExactFromParsed(rFamily, rV4, rHi, rLo); + if (remote.IsLoopbackOrPrivateOrLinkLocal) + return CreateExactFromParsed(in remote); if (exactIfSameSubnetAsCurrentIp) { - if (!requireCurrentIpIsLocalForExact || currentIp.IsLocal) + if (!requireCurrentIpIsLocalForExact || currentIp.IsLoopbackOrPrivateOrLinkLocal) { - if (rFamily == currentIp.Family) + if (remote.Family == currentIp.Family) { - if (rFamily == IpFamily.IPv4) + if (remote.Family == IpFamily.IPv4) { - if (MaskV4(rV4, v4LocalPrefixBits) == MaskV4(currentIp.V4, v4LocalPrefixBits)) - return CreateExactFromParsed(rFamily, rV4, rHi, rLo); + if (MaskV4(remote.V4, v4LocalPrefixBits) == MaskV4(currentIp.V4, v4LocalPrefixBits)) + return CreateExactFromParsed(in remote); } else { - ulong rNetHi = rHi, rNetLo = rLo; + ulong rNetHi = remote.Hi, rNetLo = remote.Lo; ulong cNetHi = currentIp.Hi, cNetLo = currentIp.Lo; MaskV6(ref rNetHi, ref rNetLo, v6LocalPrefixBits); MaskV6(ref cNetHi, ref cNetLo, v6LocalPrefixBits); if (rNetHi == cNetHi && rNetLo == cNetLo) - return CreateExactFromParsed(rFamily, rV4, rHi, rLo); + return CreateExactFromParsed(in remote); } } } } - return rFamily == IpFamily.IPv4 - ? CreateFromV4(rV4, v4BucketPrefixBits) - : CreateFromV6(rHi, rLo, v6BucketPrefixBits); + return CreateFromParsed(in remote, v4BucketPrefixBits, v6BucketPrefixBits); } - public static bool IsLoopbackOrPrivateOrLinkLocal(IPAddress ip) - { - IpFamily family = ReadAddress(ip, out uint v4, out ulong hi, out ulong lo); - return IsLoopbackOrPrivateOrLinkLocal(family, v4, hi, lo); - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static IpSubnetKey CreateExactFromParsed(in ParsedIPAddress parsed) + => CreateFromParsed(in parsed, 32, 128); [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static IpSubnetKey CreateExactFromParsed(IpFamily family, uint v4, ulong hi, ulong lo) - => family == IpFamily.IPv4 ? CreateFromV4(v4, 32) : CreateFromV6(hi, lo, 128); + private static IpSubnetKey CreateFromParsed(in ParsedIPAddress parsed, byte v4PrefixBits, byte v6PrefixBits) + => parsed.Family == IpFamily.IPv4 + ? CreateFromV4(parsed.V4, v4PrefixBits) + : CreateFromV6(parsed.Hi, parsed.Lo, v6PrefixBits); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static IpSubnetKey CreateFromV4(uint v4, byte prefixBits) @@ -333,46 +219,6 @@ private IpSubnetKey(ulong hi, ulong lo, ushort meta) private static ushort MakeMeta(IpFamily family, byte prefixBits) => (ushort)(((byte)family << 8) | prefixBits); - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static IpFamily ReadAddress(IPAddress ip, out uint v4, out ulong hi, out ulong lo) - { - Span bytes = stackalloc byte[16]; - if (!ip.TryWriteBytes(bytes, out int written)) - throw new ArgumentException("Invalid IPAddress.", nameof(ip)); - - switch (written) - { - case 4: - v4 = BinaryPrimitives.ReadUInt32BigEndian(bytes); - hi = 0; - lo = 0; - return IpFamily.IPv4; - case 16: - { - hi = BinaryPrimitives.ReadUInt64BigEndian(bytes); - - // Fast-path IPv4-mapped IPv6 (::ffff:a.b.c.d) - treat as IPv4. - if (hi == 0) - { - uint mid = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4)); - if (mid == 0x0000_FFFFu) - { - v4 = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(12, 4)); - hi = 0; - lo = 0; - return IpFamily.IPv4; - } - } - - v4 = 0; - lo = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); - return IpFamily.IPv6; - } - default: - throw new ArgumentException("Unsupported address length.", nameof(ip)); - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ulong MaskV4(uint v4, byte prefixBits) { @@ -413,32 +259,5 @@ private static void MaskV6Trusted(ref ulong hi, ref ulong lo, byte prefixBits) return; } } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool IsLoopbackOrPrivateOrLinkLocal(IpFamily family, uint v4, ulong hi, ulong lo) - { - if (family == IpFamily.IPv4) - { - byte a = (byte)(v4 >> 24); - byte b = (byte)(v4 >> 16); - - return a == 127 // Loopback: 127.0.0.0/8 - || a == 10 // RFC1918: 10.0.0.0/8 - || a == 172 && (uint)(b - 16) <= 15u // RFC1918: 172.16.0.0/12 - || a == 192 && b == 168 // RFC1918: 192.168.0.0/16 - || a == 169 && b == 254 // IPv4 link-local: 169.254.0.0/16 - || a == 100 && (b & 0xC0) == 0x40; // CGNAT: 100.64.0.0/10 - } - - // IPv6 loopback: ::1 - if (hi == 0 && lo == 1) - return true; - - byte first = (byte)(hi >> 56); - byte second = (byte)(hi >> 48); - - return (first & 0xFE) == 0xFC // ULA: fc00::/7 - || first == 0xFE && (second & 0xC0) == 0x80; // IPv6 link-local: fe80::/10 - } } } diff --git a/src/Nethermind/Nethermind.Network/NodesLoader.cs b/src/Nethermind/Nethermind.Network/NodesLoader.cs index ec36aeb8146d..20d7b8d18fd6 100644 --- a/src/Nethermind/Nethermind.Network/NodesLoader.cs +++ b/src/Nethermind/Nethermind.Network/NodesLoader.cs @@ -18,25 +18,29 @@ namespace Nethermind.Network /// /// This class should be split into multiple sources /// + public sealed record NodesLoaderOptions(bool LoadBootnodesAsPeerCandidates = true); + public class NodesLoader( INetworkConfig networkConfig, INodeStatsManager stats, [KeyFilter(DbNames.PeersDb)] INetworkStorage peerStorage, IEnode enode, - ILogManager logManager) : INodeSource + ILogManager logManager, + NodesLoaderOptions options) : INodeSource { - private readonly INetworkConfig _networkConfig = networkConfig; - private readonly INodeStatsManager _stats = stats; - private readonly INetworkStorage _peerStorage = peerStorage; - private readonly IEnode _enode = enode; - private readonly ILogger _logger = logManager.GetClassLogger(); + private readonly INetworkConfig _networkConfig = networkConfig ?? throw new ArgumentNullException(nameof(networkConfig)); + private readonly INodeStatsManager _stats = stats ?? throw new ArgumentNullException(nameof(stats)); + private readonly INetworkStorage _peerStorage = peerStorage ?? throw new ArgumentNullException(nameof(peerStorage)); + private readonly IEnode _enode = enode ?? throw new ArgumentNullException(nameof(enode)); + private readonly ILogger _logger = logManager?.GetClassLogger() ?? throw new ArgumentNullException(nameof(logManager)); + private readonly NodesLoaderOptions _options = options ?? throw new ArgumentNullException(nameof(options)); public IAsyncEnumerable DiscoverNodes(CancellationToken cancellationToken) { List allPeers = []; LoadPeersFromDb(allPeers); - if (!_networkConfig.OnlyStaticPeers) + if (!_networkConfig.OnlyStaticPeers && _options.LoadBootnodesAsPeerCandidates) { LoadConfigPeers(allPeers, _networkConfig.Bootnodes, n => { diff --git a/src/Nethermind/Nethermind.Network/ParsedIPAddress.cs b/src/Nethermind/Nethermind.Network/ParsedIPAddress.cs new file mode 100644 index 000000000000..d55176a78d77 --- /dev/null +++ b/src/Nethermind/Nethermind.Network/ParsedIPAddress.cs @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System; +using System.Buffers.Binary; +using System.Net; +using System.Runtime.CompilerServices; + +namespace Nethermind.Network; + +internal enum IpFamily : byte { IPv4 = 4, IPv6 = 6 } + +internal readonly struct ParsedIPAddress(IpFamily family, uint v4, ulong hi, ulong lo) +{ + public readonly IpFamily Family = family; + public readonly uint V4 = v4; + public readonly ulong Hi = hi; + public readonly ulong Lo = lo; + + internal static ParsedIPAddress Parse(IPAddress ipAddress) + { + ArgumentNullException.ThrowIfNull(ipAddress); + + Span bytes = stackalloc byte[16]; + if (!ipAddress.TryWriteBytes(bytes, out int written)) + { + throw new ArgumentException("Invalid IPAddress.", nameof(ipAddress)); + } + + switch (written) + { + case 4: + return new ParsedIPAddress( + IpFamily.IPv4, + BinaryPrimitives.ReadUInt32BigEndian(bytes), + hi: 0, + lo: 0); + case 16: + { + ulong hi = BinaryPrimitives.ReadUInt64BigEndian(bytes); + + if (hi == 0) + { + uint mid = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4)); + if (mid == 0x0000_FFFFu) + { + return new ParsedIPAddress( + IpFamily.IPv4, + BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(12, 4)), + hi: 0, + lo: 0); + } + } + + return new ParsedIPAddress( + IpFamily.IPv6, + v4: 0, + hi, + BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8))); + } + default: + throw new ArgumentException("Unsupported address length.", nameof(ipAddress)); + } + } + + public bool IsLoopbackOrPrivateOrLinkLocal + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Family == IpFamily.IPv4 + ? IsIPv4LoopbackOrPrivateOrLinkLocal(V4) + : IsIPv6LoopbackOrPrivateOrLinkLocal(Hi, Lo); + } + + public bool IsMulticast + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Family == IpFamily.IPv4 + ? IsIPv4MulticastAddress(V4) + : IsIPv6Multicast(Hi); + } + + public bool IsIPv4Multicast + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Family == IpFamily.IPv4 && IsIPv4MulticastAddress(V4); + } + + public bool IsSpecialUseAddress + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => Family == IpFamily.IPv4 + ? IsIPv4SpecialUseAddress(V4) + : IsIPv6SpecialUseAddress(Hi, Lo); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsIPv4MulticastAddress(uint v4) + => (byte)(v4 >> 24) is >= 224 and <= 239; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsIPv4LoopbackOrPrivateOrLinkLocal(uint v4) + { + byte a = (byte)(v4 >> 24); + byte b = (byte)(v4 >> 16); + + return a == 127 // Loopback: 127.0.0.0/8 + || a == 10 // RFC1918: 10.0.0.0/8 + || a == 172 && (uint)(b - 16) <= 15u // RFC1918: 172.16.0.0/12 + || a == 192 && b == 168 // RFC1918: 192.168.0.0/16 + || a == 169 && b == 254 // IPv4 link-local: 169.254.0.0/16 + || a == 100 && (b & 0xC0) == 0x40; // CGNAT: 100.64.0.0/10 + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsIPv6LoopbackOrPrivateOrLinkLocal(ulong hi, ulong lo) + { + if (hi == 0 && lo == 1) + { + return true; + } + + byte first = (byte)(hi >> 56); + byte second = (byte)(hi >> 48); + + return (first & 0xFE) == 0xFC // ULA: fc00::/7 + || first == 0xFE && (second & 0xC0) == 0x80; // IPv6 link-local: fe80::/10 + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIPv4SpecialUseAddress(uint v4) + { + byte a = (byte)(v4 >> 24); + byte b = (byte)(v4 >> 16); + byte c = (byte)(v4 >> 8); + + return a == 0 // 0.0.0.0/8 + || a == 192 && b == 0 && c is 0 or 2 // 192.0.0.0/24, 192.0.2.0/24 + || a == 192 && b == 31 && c == 196 // 192.31.196.0/24 + || a == 192 && b == 52 && c == 193 // 192.52.193.0/24 + || a == 192 && b == 88 && c == 99 // 192.88.99.0/24 + || a == 192 && b == 175 && c == 48 // 192.175.48.0/24 + || a == 198 && b is 18 or 19 // 198.18.0.0/15 + || a == 198 && b == 51 && c == 100 // 198.51.100.0/24 + || a == 203 && b == 0 && c == 113 // 203.0.113.0/24 + || a >= 224; // 224.0.0.0/4, 240.0.0.0/4 + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIPv6SpecialUseAddress(ulong hi, ulong lo) + { + byte b0 = (byte)(hi >> 56); + byte b1 = (byte)(hi >> 48); + byte b2 = (byte)(hi >> 40); + byte b3 = (byte)(hi >> 32); + byte b4 = (byte)(hi >> 24); + byte b5 = (byte)(hi >> 16); + + return b0 == 0x00 && b1 == 0x64 && b2 == 0xff && b3 == 0x9b && IsZeroFromByte4To11(hi, lo) // 64:ff9b::/96 + || b0 == 0x00 && b1 == 0x64 && b2 == 0xff && b3 == 0x9b && b4 == 0x00 && b5 == 0x01 // 64:ff9b:1::/48 + || b0 == 0x01 && (hi & 0x00FF_FFFF_FFFF_FFFFUL) == 0 // 100::/64 + || b0 == 0x20 && b1 == 0x01 && (b2 & 0xfe) == 0x00 // 2001::/23 + || b0 == 0x20 && b1 == 0x01 && b2 == 0x0d && b3 == 0xb8 // 2001:db8::/32 + || b0 == 0x20 && b1 == 0x02 // 2002::/16 + || b0 == 0x3f && b1 == 0xff && (b2 & 0xf0) == 0x00; // 3fff::/20 + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIPv6Multicast(ulong hi) + => (byte)(hi >> 56) == 0xff; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsZeroFromByte4To11(ulong hi, ulong lo) + => (hi & 0x0000_0000_FFFF_FFFFUL) == 0 + && (lo & 0xFFFF_FFFF_0000_0000UL) == 0; +} diff --git a/src/Nethermind/Nethermind.Network/PeerPool.cs b/src/Nethermind/Nethermind.Network/PeerPool.cs index 629be9fa089c..cfbe65954f7f 100644 --- a/src/Nethermind/Nethermind.Network/PeerPool.cs +++ b/src/Nethermind/Nethermind.Network/PeerPool.cs @@ -190,6 +190,7 @@ private async Task RunPeerCommit() } catch (Exception ex) { + _peerStorage.StartBatch(); if (_logger.IsError) ErrorPeerStorageCommit(ex); } } diff --git a/src/Nethermind/Nethermind.Runner.Test/ConfigFilesTests.cs b/src/Nethermind/Nethermind.Runner.Test/ConfigFilesTests.cs index ae8f48c9bcab..62d6d2d01913 100644 --- a/src/Nethermind/Nethermind.Runner.Test/ConfigFilesTests.cs +++ b/src/Nethermind/Nethermind.Runner.Test/ConfigFilesTests.cs @@ -152,9 +152,9 @@ public void Json_defaults_are_correct(string configWildcard, bool jsonEnabled) Test(configWildcard, static c => c.Host, "127.0.0.1"); } - [TestCase("sepolia", DiscoveryVersion.V4)] - [TestCase("hoodi", DiscoveryVersion.V4)] - [TestCase("mainnet", DiscoveryVersion.V4)] + [TestCase("sepolia", DiscoveryVersion.V5)] + [TestCase("hoodi", DiscoveryVersion.V5)] + [TestCase("mainnet", DiscoveryVersion.All)] public void Discovery_versions_are_correct(string configWildcard, DiscoveryVersion discoveryVersion) => Test(configWildcard, static c => c.DiscoveryVersion, discoveryVersion); diff --git a/src/Nethermind/Nethermind.Runner.Test/DatabasePurgerTests.cs b/src/Nethermind/Nethermind.Runner.Test/DatabasePurgerTests.cs index c64b1b1f6ae6..20667b65ceff 100644 --- a/src/Nethermind/Nethermind.Runner.Test/DatabasePurgerTests.cs +++ b/src/Nethermind/Nethermind.Runner.Test/DatabasePurgerTests.cs @@ -90,6 +90,7 @@ public void PurgeDb_deletes_network_directories() { Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.PeersDb)), Is.False); Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.DiscoveryNodes)), Is.False); + Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.DiscoveryV5Nodes)), Is.False); } } diff --git a/src/Nethermind/Nethermind.Runner.Test/Module/MainProcessingContextTests.cs b/src/Nethermind/Nethermind.Runner.Test/Module/MainProcessingContextTests.cs index 44e153dee55c..57da898a2bb6 100644 --- a/src/Nethermind/Nethermind.Runner.Test/Module/MainProcessingContextTests.cs +++ b/src/Nethermind/Nethermind.Runner.Test/Module/MainProcessingContextTests.cs @@ -28,7 +28,7 @@ public async Task Test_TransactionProcessed_EventIsFired(CancellationToken cance .AddModule(new TestNethermindModule(Cancun.Instance)) .WithGenesisPostProcessor((_, state) => { - state.AddToBalanceAndCreateIfNotExists(TestItem.AddressA, 10.Ether, Osaka.Instance); + state.AddToBalanceAndCreateIfNotExists(TestItem.PrivateKeyA.Address, 10.Ether, Osaka.Instance); }) .Build(); @@ -40,7 +40,7 @@ public async Task Test_TransactionProcessed_EventIsFired(CancellationToken cance await ctx.Resolve().AddBlockAndWaitForHead(false, cancellationToken, Build.A.Transaction .WithGasLimit(100_000) - .WithSenderAddress(TestItem.AddressA) + .WithSenderAddress(TestItem.PrivateKeyA.Address) .WithCode(Prepare.EvmCode .ForInitOf(Prepare.EvmCode .PushData(TestItem.PrivateKeyB.Address) diff --git a/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs b/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs index fe8b758a4dd6..48db2f11f070 100644 --- a/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs +++ b/src/Nethermind/Nethermind.Runner.Test/Module/NetworkModuleTest.cs @@ -20,7 +20,7 @@ using Nethermind.Network; using Nethermind.Network.Config; using Nethermind.Network.Contract.P2P; -using Nethermind.Network.Discovery; +using Nethermind.Network.Discovery.Discv4; using Nethermind.Network.P2P; using Nethermind.Network.P2P.Messages; using Nethermind.Network.P2P.Subprotocols.Eth.V71; diff --git a/src/Nethermind/Nethermind.Runner/Nethermind.Runner.csproj b/src/Nethermind/Nethermind.Runner/Nethermind.Runner.csproj index bdf774b26166..46e1faaf6280 100644 --- a/src/Nethermind/Nethermind.Runner/Nethermind.Runner.csproj +++ b/src/Nethermind/Nethermind.Runner/Nethermind.Runner.csproj @@ -64,6 +64,7 @@ + diff --git a/src/Nethermind/Nethermind.Runner/Program.cs b/src/Nethermind/Nethermind.Runner/Program.cs index a20c39723e34..3d6c94988510 100644 --- a/src/Nethermind/Nethermind.Runner/Program.cs +++ b/src/Nethermind/Nethermind.Runner/Program.cs @@ -24,6 +24,7 @@ using Nethermind.Init.Snapshot; using Nethermind.KeyStore.Config; using Nethermind.Logging; +using Nethermind.Logging.Microsoft; using Nethermind.Logging.NLog; using Nethermind.Runner; using Nethermind.Runner.Ethereum; @@ -38,7 +39,6 @@ using NullLogger = Nethermind.Logging.NullLogger; using DotNettyLoggerFactory = DotNetty.Common.Internal.Logging.InternalLoggerFactory; using Testably.Abstractions; -using Nethermind.Network.Discovery.Discv5; #if !DEBUG using DotNettyLeakDetector = DotNetty.Common.ResourceLeakDetector; #endif diff --git a/src/Nethermind/Nethermind.Runner/configs/hoodi.json b/src/Nethermind/Nethermind.Runner/configs/hoodi.json index a593098daba8..6d1f4871a158 100644 --- a/src/Nethermind/Nethermind.Runner/configs/hoodi.json +++ b/src/Nethermind/Nethermind.Runner/configs/hoodi.json @@ -33,6 +33,6 @@ "Enabled": true }, "Discovery": { - "Bootnodes": "enode://2112dd3839dd752813d4df7f40936f06829fc54c0e051a93967c26e5f5d27d99d886b57b4ffcc3c475e930ec9e79c56ef1dbb7d86ca5ee83a9d2ccf36e5c240c@134.209.138.84:30303,enode://60203fcb3524e07c5df60a14ae1c9c5b24023ea5d47463dfae051d2c9f3219f309657537576090ca0ae641f73d419f53d8e8000d7a464319d4784acd7d2abc41@209.38.124.160:30303,enode://8ae4a48101b2299597341263da0deb47cc38aa4d3ef4b7430b897d49bfa10eb1ccfe1655679b1ed46928ef177fbf21b86837bd724400196c508427a6f41602cd@134.199.184.23:30303" + "DiscoveryVersion": "V5" } } diff --git a/src/Nethermind/Nethermind.Runner/configs/hoodi_archive.json b/src/Nethermind/Nethermind.Runner/configs/hoodi_archive.json index e19494af6e6b..ccf754d6d681 100644 --- a/src/Nethermind/Nethermind.Runner/configs/hoodi_archive.json +++ b/src/Nethermind/Nethermind.Runner/configs/hoodi_archive.json @@ -33,6 +33,9 @@ "Merge": { "Enabled": true }, + "Discovery": { + "DiscoveryVersion": "V5" + }, "FlatDb": { "PersistenceWriteBufferFloor": 67108864 } diff --git a/src/Nethermind/Nethermind.Runner/configs/mainnet.json b/src/Nethermind/Nethermind.Runner/configs/mainnet.json index 2cfd8f1af867..2fff126ad3f1 100644 --- a/src/Nethermind/Nethermind.Runner/configs/mainnet.json +++ b/src/Nethermind/Nethermind.Runner/configs/mainnet.json @@ -37,5 +37,8 @@ }, "Merge": { "Enabled": true + }, + "Discovery": { + "DiscoveryVersion": "All" } -} \ No newline at end of file +} diff --git a/src/Nethermind/Nethermind.Runner/configs/mainnet_archive.json b/src/Nethermind/Nethermind.Runner/configs/mainnet_archive.json index 55f827b7c310..71983bfc4a43 100644 --- a/src/Nethermind/Nethermind.Runner/configs/mainnet_archive.json +++ b/src/Nethermind/Nethermind.Runner/configs/mainnet_archive.json @@ -38,6 +38,9 @@ "Merge": { "FinalTotalDifficulty": "58750003716598352816469" }, + "Discovery": { + "DiscoveryVersion": "All" + }, "FlatDb": { "PersistenceWriteBufferFloor": 67108864 } diff --git a/src/Nethermind/Nethermind.Runner/configs/sepolia.json b/src/Nethermind/Nethermind.Runner/configs/sepolia.json index fbc404e78f13..8e66451a94bc 100644 --- a/src/Nethermind/Nethermind.Runner/configs/sepolia.json +++ b/src/Nethermind/Nethermind.Runner/configs/sepolia.json @@ -38,5 +38,8 @@ }, "Merge": { "Enabled": true + }, + "Discovery": { + "DiscoveryVersion": "V5" } -} \ No newline at end of file +} diff --git a/src/Nethermind/Nethermind.Runner/configs/sepolia_archive.json b/src/Nethermind/Nethermind.Runner/configs/sepolia_archive.json index ef487cf10aa8..af06fb4a7d36 100644 --- a/src/Nethermind/Nethermind.Runner/configs/sepolia_archive.json +++ b/src/Nethermind/Nethermind.Runner/configs/sepolia_archive.json @@ -36,6 +36,9 @@ "Pruning": { "Mode": "None" }, + "Discovery": { + "DiscoveryVersion": "V5" + }, "FlatDb": { "PersistenceWriteBufferFloor": 67108864 } diff --git a/src/Nethermind/Nethermind.Runner/packages.lock.json b/src/Nethermind/Nethermind.Runner/packages.lock.json index 0d0db0c05115..5f65c323be9d 100644 --- a/src/Nethermind/Nethermind.Runner/packages.lock.json +++ b/src/Nethermind/Nethermind.Runner/packages.lock.json @@ -134,11 +134,6 @@ "NETStandard.Library": "1.6.1" } }, - "Keccak256": { - "type": "Transitive", - "resolved": "1.0.0", - "contentHash": "duyRtj4I3+yZZZC7Ma5S/cxzWn5CLPRcXeXtmBcLS3TpjwLm74afQEGzfYEWma8H/dbpUiHl2ozYszKuQ8QpEg==" - }, "libsodium": { "type": "Transitive", "resolved": "1.0.20", @@ -289,33 +284,11 @@ "System.CodeDom": "4.4.0" } }, - "Multiformats.Base": { - "type": "Transitive", - "resolved": "2.0.2", - "contentHash": "uMUDZLjkdI7zrkRFCC7tPV//1y9NnFNQnvyrzoLrn9lPNvSGQhHoA5BEBxO58S5Ow3R580UP8W6mfWDKtIuSYQ==" - }, - "Multiformats.Hash": { - "type": "Transitive", - "resolved": "1.5.0", - "contentHash": "f9HstrBNHUWs0WFhYH7H4H3VatzTVop+XWp0QDFW7f9JzeIj2fnz21P0IrgwR8H6wl1ujAEh+5yf30XlqRDcaQ==", - "dependencies": { - "BinaryEncoding": "1.4.0", - "Multiformats.Base": "2.0.1", - "Portable.BouncyCastle": "1.8.5", - "System.Composition": "1.2.0", - "murmurhash": "1.0.2" - } - }, "murmurhash": { "type": "Transitive", "resolved": "1.0.2", "contentHash": "Yw9+sYL3qdTEXDKAEeiXsVwsP2K2nyWOxgvbDD1w5j+yu0CYk5edLvGmmJHqqFxuBFrVsgb7iF2XGprRlt+SEA==" }, - "NBitcoin.Secp256k1": { - "type": "Transitive", - "resolved": "3.1.5", - "contentHash": "HGOj4qoTGdHQ6lYjGOmYrxMgbTyyXonunPq+btFalAedumQ2tJxykiMlygEGNnEUoOXCAIV4fvbCdCthFw3LOQ==" - }, "Nethermind.DotNetty.Codecs": { "type": "Transitive", "resolved": "1.0.2.76", @@ -501,33 +474,11 @@ "libsodium": "1.0.16" } }, - "PierTwo.Lantern.Discv5.Enr": { - "type": "Transitive", - "resolved": "1.0.0-preview.8", - "contentHash": "NI1titqkA2KwIgNdPMJuLPNirgAPTNaL7K7x2Qf6RQpPI6AbMoGO0ny6CL4H/VLMVYQVzT1NzwLqJ78wNeUYJg==", - "dependencies": { - "Keccak256": "1.0.0", - "Multiformats.Base": "2.0.2", - "Multiformats.Hash": "1.5.0", - "NBitcoin.Secp256k1": "3.1.5", - "PierTwo.Lantern.Discv5.Rlp": "1.0.0-preview.8" - } - }, - "PierTwo.Lantern.Discv5.Rlp": { - "type": "Transitive", - "resolved": "1.0.0-preview.8", - "contentHash": "d50BMHF1g7rgcJLJmu7ytqFYRmMfkBkc2VddzTFVmEVPzb2Uk7genfObgwqMtvmHbYk6zQE57f2r5oZwU5B08g==" - }, "Polly.Core": { "type": "Transitive", "resolved": "8.6.6", "contentHash": "lCBL9mmhF9TZxHG3beVRkyjlLohkIC464xIAq7J7Y59C+z42hmsdUaeCKl2SIAYertOUU5TeBXyQDLDQGIKePQ==" }, - "Portable.BouncyCastle": { - "type": "Transitive", - "resolved": "1.8.5", - "contentHash": "EaCgmntbH1sOzemRTqyXSqYjB6pLH7VCYHhhDYZ59guHSD5qPwhIYa7kfy0QUlmTRt9IXhaXdFhNuBUArp70Ng==" - }, "prometheus-net": { "type": "Transitive", "resolved": "8.2.1", @@ -670,6 +621,7 @@ "type": "Project", "dependencies": { "Nethermind.Core": "[1.39.0-unstable, )", + "Nethermind.Network.Enr": "[1.39.0-unstable, )", "NonBlocking": "[2.1.2, )", "System.Configuration.ConfigurationManager": "[10.0.9, )" } @@ -953,6 +905,13 @@ "Nethermind.Init": "[1.39.0-unstable, )" } }, + "nethermind.kademlia": { + "type": "Project", + "dependencies": { + "Collections.Pooled": "[1.0.82, )", + "Nethermind.Logging": "[1.39.0-unstable, )" + } + }, "nethermind.keystore": { "type": "Project", "dependencies": { @@ -966,6 +925,12 @@ "nethermind.logging": { "type": "Project" }, + "nethermind.logging.microsoft": { + "type": "Project", + "dependencies": { + "Nethermind.Logging": "[1.39.0-unstable, )" + } + }, "nethermind.logging.nlog": { "type": "Project", "dependencies": { @@ -1026,12 +991,13 @@ "nethermind.network.discovery": { "type": "Project", "dependencies": { + "Collections.Pooled": "[1.0.82, )", "Nethermind.Api": "[1.39.0-unstable, )", "Nethermind.Crypto": "[1.39.0-unstable, )", "Nethermind.Facade": "[1.39.0-unstable, )", + "Nethermind.Kademlia": "[1.39.0-unstable, )", "Nethermind.Network": "[1.39.0-unstable, )", - "Nethermind.Network.Enr": "[1.39.0-unstable, )", - "PierTwo.Lantern.Discv5.WireProtocol": "[1.0.0-preview.8, )" + "Nethermind.Network.Enr": "[1.39.0-unstable, )" } }, "nethermind.network.dns": { @@ -1046,7 +1012,7 @@ "type": "Project", "dependencies": { "Nethermind.Crypto": "[1.39.0-unstable, )", - "Nethermind.Network": "[1.39.0-unstable, )" + "Nethermind.Serialization.Rlp": "[1.39.0-unstable, )" } }, "nethermind.network.stats": { @@ -1055,7 +1021,8 @@ "Nethermind.Config": "[1.39.0-unstable, )", "Nethermind.Core": "[1.39.0-unstable, )", "Nethermind.Logging": "[1.39.0-unstable, )", - "Nethermind.Network.Contract": "[1.39.0-unstable, )" + "Nethermind.Network.Contract": "[1.39.0-unstable, )", + "Nethermind.Network.Enr": "[1.39.0-unstable, )" } }, "nethermind.opcodetracing.plugin": { @@ -1124,6 +1091,7 @@ "Nethermind.Init": "[1.39.0-unstable, )", "Nethermind.Libp2p": "[1.0.0-preview.45, )", "Nethermind.Libp2p.Protocols.PubsubPeerDiscovery": "[1.0.0-preview.45, )", + "Nethermind.Logging.Microsoft": "[1.39.0-unstable, )", "Nethermind.Merge.Plugin": "[1.39.0-unstable, )", "Nethermind.Network.Discovery": "[1.39.0-unstable, )", "Nethermind.Serialization.Ssz": "[1.39.0-unstable, )", @@ -1603,18 +1571,6 @@ "resolved": "2.1.0.5", "contentHash": "F/4WoNK1rYCMGZM6B1LVlgxf2wLogJc2ohMZxwmJw7Aky2Hc1IgFZvEj/cxcv5QQSFTvPN5AWYKomFXHukOUIg==" }, - "PierTwo.Lantern.Discv5.WireProtocol": { - "type": "CentralTransitive", - "requested": "[1.0.0-preview.8, )", - "resolved": "1.0.0-preview.8", - "contentHash": "mSHH0TEVdN2dQhvVnBrAUbSQiszO4YcjKkCurQJJxzBoYCp6R//ckfRa87fFkdqWKXJFHPJf2fWgd0vSmyB/Cw==", - "dependencies": { - "BouncyCastle.Cryptography": "2.4.0", - "NBitcoin.Secp256k1": "3.1.5", - "PierTwo.Lantern.Discv5.Enr": "1.0.0-preview.8", - "PierTwo.Lantern.Discv5.Rlp": "1.0.0-preview.8" - } - }, "Polly": { "type": "CentralTransitive", "requested": "[8.6.6, )", diff --git a/src/Nethermind/Nethermind.Serialization.Ssz/SszSerializableAttribute.cs b/src/Nethermind/Nethermind.Serialization.Ssz/SszSerializableAttribute.cs index f5c493fec8d1..341d443e85ae 100644 --- a/src/Nethermind/Nethermind.Serialization.Ssz/SszSerializableAttribute.cs +++ b/src/Nethermind/Nethermind.Serialization.Ssz/SszSerializableAttribute.cs @@ -33,9 +33,9 @@ public class SszFieldAttribute(int index) : Attribute } [AttributeUsage(AttributeTargets.Property)] -public class SszListAttribute(int limit) : Attribute +public class SszListAttribute(ulong limit) : Attribute { - public int Limit { get; } = limit; + public ulong Limit { get; } = limit; } [AttributeUsage(AttributeTargets.Property)] diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/EncodingTest.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/EncodingTest.cs index 2f9246217d9f..66a994edaa21 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/EncodingTest.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/EncodingTest.cs @@ -99,6 +99,41 @@ public void Decode_bitvector_preserves_declared_length() } } + [Test] + public void Supports_list_limits_beyond_int_range() + { + const ulong Limit = 1_099_511_627_776; // 2^40, VALIDATOR_REGISTRY_LIMIT + ulong[] basicItems = [1, 2, 3]; + FixedC[] compositeItems = [new() { Fixed1 = 1, Fixed2 = 2 }, new() { Fixed1 = 3, Fixed2 = 4 }]; + + HugeLimitBasicList basicList = new() { Items = basicItems }; + byte[] encoded = HugeLimitBasicList.Encode(basicList); + HugeLimitBasicList.Decode(encoded, out HugeLimitBasicList decodedBasic); + HugeLimitBasicList.Merkleize(basicList, out UInt256 basicRoot); + + // Reference roots computed via the runtime ulong-limit merkleization primitives + Merkle.Merkleize(out UInt256 expectedBasicRoot, MemoryMarshal.AsBytes(basicItems), Limit / 4); + Merkle.MixIn(ref expectedBasicRoot, basicItems.Length); + + HugeLimitCompositeList compositeList = new() { Items = compositeItems }; + HugeLimitCompositeList.Merkleize(compositeList, out UInt256 compositeRoot); + + Span itemRoots = stackalloc UInt256[compositeItems.Length]; + for (int i = 0; i < compositeItems.Length; i++) + { + FixedC.Merkleize(compositeItems[i], out itemRoots[i]); + } + Merkle.Merkleize(out UInt256 expectedCompositeRoot, itemRoots, Limit); + Merkle.MixIn(ref expectedCompositeRoot, compositeItems.Length); + + using (Assert.EnterMultipleScope()) + { + Assert.That(decodedBasic.Items, Is.EqualTo(basicItems)); + Assert.That(basicRoot, Is.EqualTo(expectedBasicRoot)); + Assert.That(compositeRoot, Is.EqualTo(expectedCompositeRoot)); + } + } + [Test] public void Encode_and_decode_signed_primitive_collections_round_trip() { diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszGeneratorDiagnosticTest.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszGeneratorDiagnosticTest.cs index 23f1cab476cc..ba45ec5c6a7c 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszGeneratorDiagnosticTest.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszGeneratorDiagnosticTest.cs @@ -219,6 +219,26 @@ public static void Feed(ref Merkleizer merkleizer, DuplicateFixedBytes value) Assert.That(diagnostic.GetMessage(), Does.Contain("Multiple SSZ converters")); } + [Test] + public void Bitlist_with_limit_beyond_int_range_reports_diagnostic() + { + const string source = """ + using System.Collections; + using Nethermind.Serialization.Ssz; + + [SszContainer] + public partial struct HugeBitlistContainer + { + [SszList(1_099_511_627_776)] + public BitArray? Bits { get; set; } + } + """; + + CSharpParseOptions parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview); + Diagnostic diagnostic = GetSsz003Diagnostic(source, parseOptions, nameof(Bitlist_with_limit_beyond_int_range_reports_diagnostic)); + Assert.That(diagnostic.GetMessage(), Does.Contain("BitArray cannot exceed int.MaxValue bits")); + } + [Test] public void Converter_backed_primitive_collections_emit_converter_calls() { diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszTypes.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszTypes.cs index 78ca4ceb3f5c..e9657bd25915 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszTypes.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator.Test/SszTypes.cs @@ -463,4 +463,19 @@ public partial class ShadowDerived : ShadowBase public new uint X { get; set; } } + [SszContainer(isCollectionItself: true)] + public partial struct HugeLimitBasicList + { + // VALIDATOR_REGISTRY_LIMIT-sized list (2^40), exceeds int.MaxValue + [SszList(1_099_511_627_776)] + public ulong[] Items { get; set; } + } + + [SszContainer(isCollectionItself: true)] + public partial struct HugeLimitCompositeList + { + [SszList(1_099_511_627_776)] + public FixedC[] Items { get; set; } + } + } diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator/Attributes.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator/Attributes.cs index be420f0c2336..7548e95634f2 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator/Attributes.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator/Attributes.cs @@ -21,9 +21,9 @@ public class SszFieldAttribute(int index) : Attribute } [AttributeUsage(AttributeTargets.Property)] -public class SszListAttribute(int limit) : Attribute +public class SszListAttribute(ulong limit) : Attribute { - public int Limit { get; } = limit; + public ulong Limit { get; } = limit; } [AttributeUsage(AttributeTargets.Property)] diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator/SszGenerator.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator/SszGenerator.cs index 207587069f58..3c5e967ee1e2 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator/SszGenerator.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator/SszGenerator.cs @@ -301,9 +301,9 @@ internal static void ValidateSszVectorLength(ReadOnlySpan items, int expec } } - internal static void ValidateSszListLimit(ReadOnlySpan items, int limit, string typeName, string fieldName) + internal static void ValidateSszListLimit(ReadOnlySpan items, ulong limit, string typeName, string fieldName) { - if (items.Length > limit) + if ((ulong)items.Length > limit) { ThrowInvalidSszValue(typeName, fieldName, $"expected at most {limit} elements but found {items.Length}."); } @@ -318,9 +318,9 @@ internal static void ValidateSszBitvectorLength(BitArray? bits, int expectedLeng } } - internal static void ValidateSszBitlistLimit(BitArray? bits, int limit, string typeName, string fieldName) + internal static void ValidateSszBitlistLimit(BitArray? bits, ulong limit, string typeName, string fieldName) { - if (bits is not null && bits.Length > limit) + if (bits is not null && (ulong)bits.Length > limit) { ThrowInvalidSszValue(typeName, fieldName, $"expected at most {limit} bits but found {bits.Length}."); } @@ -645,10 +645,9 @@ private static string ValidationStatement(SszType decl, SszProperty property, st { Kind.Vector when property.Type.Name == "BitArray" => $"ValidateSszBitvectorLength({expression}, {property.Length}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", Kind.Vector => $"ValidateSszVectorLength({SpanExpression(property, expression)}, {property.Length}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", - Kind.List when property.Type.Name == "BitArray" => $"ValidateSszBitlistLimit({expression}, {property.Limit}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", - Kind.List => $"ValidateSszListLimit({SpanExpression(property, expression)}, {property.Limit}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", + Kind.List => $"ValidateSszListLimit({SpanExpression(property, expression)}, {property.Limit}UL, nameof({decl.TypeReferenceName}), nameof({property.Name}));", Kind.BitVector => $"ValidateSszBitvectorLength({expression}, {property.Length}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", - Kind.BitList => $"ValidateSszBitlistLimit({expression}, {property.Limit}, nameof({decl.TypeReferenceName}), nameof({property.Name}));", + Kind.BitList => $"ValidateSszBitlistLimit({expression}, {property.Limit}UL, nameof({decl.TypeReferenceName}), nameof({property.Name}));", _ => string.Empty, }; @@ -783,7 +782,8 @@ private static string DecodeAndAssign(SszType decl, SszProperty property, string ? string.Empty : $"int __count = {sliceExpression}.Length / {itemSize};"; string countExpression = property.Kind == Kind.Vector ? property.Length!.Value.ToString() : "__count"; - string limitGuard = (property.Kind == Kind.List && property.Limit.HasValue) + // No guard for limits beyond int.MaxValue: an int-typed count can never exceed them + string limitGuard = (property.Kind == Kind.List && property.Limit is <= int.MaxValue) ? $"if (__count > {property.Limit.Value}) throw new System.IO.InvalidDataException($\"{decl.TypeReferenceName}.{property.Name}: list count {{__count}} exceeds SSZ limit {property.Limit.Value}\");" : string.Empty; string assignment = DecodeAssignmentExpression(property, variableName, sourceIsArray: true); @@ -803,7 +803,8 @@ private static string DecodeAndAssign(SszType decl, SszProperty property, string ? string.Empty : $"int __count = {sliceExpression}.Length / {itemSize};"; string countExpression = property.Kind == Kind.Vector ? property.Length!.Value.ToString() : "__count"; - string limitGuard = (property.Kind == Kind.List && property.Limit.HasValue) + // No guard for limits beyond int.MaxValue: an int-typed count can never exceed them + string limitGuard = (property.Kind == Kind.List && property.Limit is <= int.MaxValue) ? $"if (__count > {property.Limit.Value}) throw new System.IO.InvalidDataException($\"{decl.TypeReferenceName}.{property.Name}: list count {{__count}} exceeds SSZ limit {property.Limit.Value}\");" : string.Empty; string assignment = DecodeAssignmentExpression(property, variableName, sourceIsArray: true); @@ -832,7 +833,8 @@ private static string DecodeAndAssign(SszType decl, SszProperty property, string string validation2 = ValidationStatement(decl, property, $"container.{property.Name}"); string preAllocationListGuard = string.Empty; - if (property.Kind == Kind.List && property.Limit.HasValue && !property.HandledByStd) + // No guard for limits beyond int.MaxValue: an int-typed count can never exceed them + if (property.Kind == Kind.List && property.Limit is <= int.MaxValue && !property.HandledByStd) { preAllocationListGuard = property.Type.IsVariable ? $"if ({sliceExpression}.Length >= {SszType.PointerLength}) {{ int __firstOffset = DecodeSszOffset({sliceExpression}.Slice(0, {SszType.PointerLength})); int __preCount = __firstOffset / {SszType.PointerLength}; if (__preCount > {property.Limit.Value}) throw new System.IO.InvalidDataException($\"{decl.TypeReferenceName}.{property.Name}: list count {{__preCount}} exceeds SSZ limit {property.Limit.Value}\"); }}" @@ -882,22 +884,22 @@ private static string MerkleizeRootStatement(SszProperty property, string expres Kind.Basic when property.Type.CustomFeedMethod is not null => ConverterMerkleizeStatement(property, expression, rootName), Kind.Basic => $"Merkle.Merkleize(out {rootName}, {expression});", Kind.BitVector => $"Merkle.Merkleize(out {rootName}, {expression}!);", - Kind.BitList => $"Merkle.Merkleize(out {rootName}, {expression} ?? new BitArray(0), {property.Limit});", + Kind.BitList => $"Merkle.Merkleize(out {rootName}, {expression} ?? new BitArray(0), {property.Limit}UL);", Kind.ProgressiveBitList => $"MerkleizeProgressiveBitList({expression}, out {rootName});", Kind.Vector when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeBasicVectorWithConverter<{enumType.TypeReferenceName}>({EnumSpanExpression(property, expression)}, {enumType.StaticLength}, {property.Length}, {enumType.CustomEncodeMethod}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeBasicListWithConverter<{enumType.TypeReferenceName}>({EnumSpanExpression(property, expression)}, {enumType.StaticLength}, {property.Limit}, {enumType.CustomEncodeMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeBasicListWithConverter<{enumType.TypeReferenceName}>({EnumSpanExpression(property, expression)}, {enumType.StaticLength}, {property.Limit}UL, {enumType.CustomEncodeMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeProgressiveBasicListWithConverter<{enumType.TypeReferenceName}>({EnumSpanExpression(property, expression)}, {enumType.StaticLength}, {enumType.CustomEncodeMethod}, out {rootName});", Kind.Vector when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeBasicVectorWithConverter<{property.Type.TypeReferenceName}>({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Length}, {property.Type.CustomEncodeMethod}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeBasicListWithConverter<{property.Type.TypeReferenceName}>({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Limit}, {property.Type.CustomEncodeMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeBasicListWithConverter<{property.Type.TypeReferenceName}>({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Limit}UL, {property.Type.CustomEncodeMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeProgressiveBasicListWithConverter<{property.Type.TypeReferenceName}>({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Type.CustomEncodeMethod}, out {rootName});", Kind.Vector when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeBasicVector({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Length}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeBasicList({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Limit}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeBasicList({SpanExpression(property, expression)}, {property.Type.StaticLength}, {property.Limit}UL, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeProgressiveBasicList({SpanExpression(property, expression)}, out {rootName});", Kind.Vector when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeVectorWithConverter({SpanExpression(property, expression)}, {property.Length}, {property.Type.CustomFeedMethod}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeListWithConverter({SpanExpression(property, expression)}, {property.Limit}, {property.Type.CustomFeedMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeListWithConverter({SpanExpression(property, expression)}, {property.Limit}UL, {property.Type.CustomFeedMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeProgressiveListWithConverter({SpanExpression(property, expression)}, {property.Type.CustomFeedMethod}, out {rootName});", Kind.Vector => $"{property.Type.StaticMemberAccess}.MerkleizeVector({SpanExpression(property, expression)}, out {rootName});", - Kind.List => $"{property.Type.StaticMemberAccess}.MerkleizeList({SpanExpression(property, expression)}, {property.Limit}, out {rootName});", + Kind.List => $"{property.Type.StaticMemberAccess}.MerkleizeList({SpanExpression(property, expression)}, {property.Limit}UL, out {rootName});", Kind.ProgressiveList => $"{property.Type.StaticMemberAccess}.MerkleizeProgressiveList({SpanExpression(property, expression)}, out {rootName});", _ => $"{property.Type.StaticMemberAccess}.Merkleize({expression}, out {rootName});", }; @@ -912,15 +914,15 @@ private static string MerkleizeEmptyCollectionRootStatement(SszProperty property Kind.Vector when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeDefaultCompositeVectorWithConverter<{property.Type.TypeReferenceName}>({property.Type.StaticLength}, {property.Length}, {property.Type.CustomDecodeMethod}, {property.Type.CustomFeedMethod}, out {rootName});", Kind.Vector when property.Type.Kind == Kind.Basic => $"Merkle.Merkleize(out {rootName}, ReadOnlySpan.Empty, {property.Length});", Kind.Vector => $"{{ {property.Type.TypeReferenceName}[] __empty{property.Name} = new {property.Type.TypeReferenceName}[{property.Length}]; {property.Type.StaticMemberAccess}.MerkleizeVector(__empty{property.Name}, out {rootName}); }}", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeBasicListWithConverter<{enumType.TypeReferenceName}>(System.Runtime.InteropServices.MemoryMarshal.Cast<{property.Type.TypeReferenceName}, {enumType.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty), {enumType.StaticLength}, {property.Limit}, {enumType.CustomEncodeMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeBasicListWithConverter<{enumType.TypeReferenceName}>(System.Runtime.InteropServices.MemoryMarshal.Cast<{property.Type.TypeReferenceName}, {enumType.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty), {enumType.StaticLength}, {property.Limit}UL, {enumType.CustomEncodeMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.EnumType is { HasCustomInlineCodec: true, IsSszBasicType: true } enumType => $"MerkleizeProgressiveBasicListWithConverter<{enumType.TypeReferenceName}>(System.Runtime.InteropServices.MemoryMarshal.Cast<{property.Type.TypeReferenceName}, {enumType.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty), {enumType.StaticLength}, {enumType.CustomEncodeMethod}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeBasicListWithConverter<{property.Type.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.StaticLength}, {property.Limit}, {property.Type.CustomEncodeMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeBasicListWithConverter<{property.Type.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.StaticLength}, {property.Limit}UL, {property.Type.CustomEncodeMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec && property.Type.IsSszBasicType => $"MerkleizeProgressiveBasicListWithConverter<{property.Type.TypeReferenceName}>(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.StaticLength}, {property.Type.CustomEncodeMethod}, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeBasicList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.StaticLength}, {property.Limit}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeBasicList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.StaticLength}, {property.Limit}UL, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.IsSszBasicType => $"MerkleizeProgressiveBasicList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, out {rootName});", - Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeListWithConverter(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Limit}, {property.Type.CustomFeedMethod}, out {rootName});", + Kind.List when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeListWithConverter(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Limit}UL, {property.Type.CustomFeedMethod}, out {rootName});", Kind.ProgressiveList when property.Type.Kind == Kind.Basic && property.Type.HasCustomInlineCodec => $"MerkleizeCompositeProgressiveListWithConverter(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Type.CustomFeedMethod}, out {rootName});", - Kind.List => $"{property.Type.StaticMemberAccess}.MerkleizeList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Limit}, out {rootName});", + Kind.List => $"{property.Type.StaticMemberAccess}.MerkleizeList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, {property.Limit}UL, out {rootName});", Kind.ProgressiveList => $"{property.Type.StaticMemberAccess}.MerkleizeProgressiveList(ReadOnlySpan<{property.Type.TypeReferenceName}>.Empty, out {rootName});", _ => throw new InvalidOperationException($"Cannot merkleize an empty {property.Kind} collection."), }; @@ -1032,7 +1034,9 @@ private static string EncodeStatement(string target, SszProperty property, strin string arguments = $"{target}, {EncodeValueExpression(property, expression)}"; if (property.Kind == Kind.BitList) { - arguments += $", {property.Limit}"; + // The Encode limit parameter is int-typed; bitlist limits beyond int.MaxValue are + // rejected at parse time (a BitArray cannot exceed int.MaxValue bits), so this fits. + arguments += $", {property.Limit!.Value}"; } else if (property.Kind == Kind.ProgressiveBitList) { diff --git a/src/Nethermind/Nethermind.Serialization.SszGenerator/SszProperty.cs b/src/Nethermind/Nethermind.Serialization.SszGenerator/SszProperty.cs index 4a2aad5a16c0..f314740252f1 100644 --- a/src/Nethermind/Nethermind.Serialization.SszGenerator/SszProperty.cs +++ b/src/Nethermind/Nethermind.Serialization.SszGenerator/SszProperty.cs @@ -60,7 +60,14 @@ public static SszProperty From(SemanticModel semanticModel, List types, AttributeData? listAttr = GetAttribute(attributes, nameof(SszListAttribute)); if (listAttr is not null) { - result.Limit = listAttr.ConstructorArguments.FirstOrDefault().Value as int? ?? 0; + ulong limit = listAttr.ConstructorArguments.FirstOrDefault().Value as ulong? ?? 0UL; + if (prop.Type.Name == nameof(BitArray) && limit > int.MaxValue) + { + throw new InvalidOperationException( + $"Bitlist property {prop.ContainingType.Name}.{prop.Name} declares limit {limit}, but a BitArray cannot exceed int.MaxValue bits."); + } + + result.Limit = limit; } result.IsProgressiveList = HasAttribute(attributes, nameof(SszProgressiveListAttribute)); @@ -247,7 +254,7 @@ public int StaticLength } public int? Length { get; set; } - public int? Limit { get; set; } + public ulong? Limit { get; set; } public bool IsCompatibleWith(SszProperty other, HashSet<(SszType, SszType)> visited) { diff --git a/src/Nethermind/Nethermind.Shutter/Nethermind.Shutter.csproj b/src/Nethermind/Nethermind.Shutter/Nethermind.Shutter.csproj index 6a548d6660a3..ccb26df5ecf6 100644 --- a/src/Nethermind/Nethermind.Shutter/Nethermind.Shutter.csproj +++ b/src/Nethermind/Nethermind.Shutter/Nethermind.Shutter.csproj @@ -38,6 +38,7 @@ + diff --git a/src/Nethermind/Nethermind.Shutter/ShutterP2P.cs b/src/Nethermind/Nethermind.Shutter/ShutterP2P.cs index ca6e70417bce..e8ef780c8b7b 100644 --- a/src/Nethermind/Nethermind.Shutter/ShutterP2P.cs +++ b/src/Nethermind/Nethermind.Shutter/ShutterP2P.cs @@ -22,8 +22,8 @@ using Nethermind.Network; using Microsoft.Extensions.Logging; using Nethermind.Core; +using Nethermind.Logging.Microsoft; using System.Collections.Generic; -using Nethermind.Network.Discovery.Discv5; namespace Nethermind.Shutter; diff --git a/src/Nethermind/Nethermind.Xdc.Test/Discovery/XdcDiscoveryTests.cs b/src/Nethermind/Nethermind.Xdc.Test/Discovery/XdcDiscoveryTests.cs index 1cf6879650a1..645fa1e491ce 100644 --- a/src/Nethermind/Nethermind.Xdc.Test/Discovery/XdcDiscoveryTests.cs +++ b/src/Nethermind/Nethermind.Xdc.Test/Discovery/XdcDiscoveryTests.cs @@ -7,8 +7,8 @@ using Nethermind.Core.Test.Builders; using Nethermind.Crypto; using Nethermind.Network; -using Nethermind.Network.Discovery; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Network.Test; using Nethermind.Xdc.Discovery; using NSubstitute; diff --git a/src/Nethermind/Nethermind.Xdc/Discovery/XdcDiscoveryApp.cs b/src/Nethermind/Nethermind.Xdc/Discovery/XdcDiscoveryApp.cs index 92944b730875..483c27b29524 100644 --- a/src/Nethermind/Nethermind.Xdc/Discovery/XdcDiscoveryApp.cs +++ b/src/Nethermind/Nethermind.Xdc/Discovery/XdcDiscoveryApp.cs @@ -8,6 +8,7 @@ using Nethermind.Network; using Nethermind.Network.Config; using Nethermind.Network.Discovery; +using Nethermind.Network.Discovery.Discv4; namespace Nethermind.Xdc.Discovery; diff --git a/src/Nethermind/Nethermind.Xdc/Discovery/XdcNettyDiscoveryHandler.cs b/src/Nethermind/Nethermind.Xdc/Discovery/XdcNettyDiscoveryHandler.cs index 482610066118..e98274162baf 100644 --- a/src/Nethermind/Nethermind.Xdc/Discovery/XdcNettyDiscoveryHandler.cs +++ b/src/Nethermind/Nethermind.Xdc/Discovery/XdcNettyDiscoveryHandler.cs @@ -5,8 +5,8 @@ using Nethermind.Core; using Nethermind.Logging; using Nethermind.Network; -using Nethermind.Network.Discovery; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4; +using Nethermind.Network.Discovery.Discv4.Messages; namespace Nethermind.Xdc.Discovery; diff --git a/src/Nethermind/Nethermind.Xdc/Discovery/XdcPingMsgSerializer.cs b/src/Nethermind/Nethermind.Xdc/Discovery/XdcPingMsgSerializer.cs index 09caeac25854..969ee6e0ba11 100644 --- a/src/Nethermind/Nethermind.Xdc/Discovery/XdcPingMsgSerializer.cs +++ b/src/Nethermind/Nethermind.Xdc/Discovery/XdcPingMsgSerializer.cs @@ -3,8 +3,8 @@ using Autofac.Features.AttributeFilters; using Nethermind.Crypto; -using Nethermind.Network.Discovery.Messages; -using Nethermind.Network.Discovery.Serializers; +using Nethermind.Network.Discovery.Discv4.Messages; +using Nethermind.Network.Discovery.Discv4.Serializers; namespace Nethermind.Xdc.Discovery; diff --git a/src/Nethermind/Nethermind.Xdc/XdcModule.cs b/src/Nethermind/Nethermind.Xdc/XdcModule.cs index ec2472272822..d78bc24e12e1 100644 --- a/src/Nethermind/Nethermind.Xdc/XdcModule.cs +++ b/src/Nethermind/Nethermind.Xdc/XdcModule.cs @@ -21,8 +21,8 @@ using Nethermind.Init.Modules; using Nethermind.JsonRpc.Modules; using Nethermind.Network; -using Nethermind.Network.Discovery; -using Nethermind.Network.Discovery.Messages; +using Nethermind.Network.Discovery.Discv4; +using Nethermind.Network.Discovery.Discv4.Messages; using Nethermind.Serialization.Rlp; using Nethermind.Specs.ChainSpecStyle; using Nethermind.Synchronization; diff --git a/src/Nethermind/Nethermind.slnx b/src/Nethermind/Nethermind.slnx index d7a824919710..65f35af95e14 100644 --- a/src/Nethermind/Nethermind.slnx +++ b/src/Nethermind/Nethermind.slnx @@ -122,6 +122,7 @@ +