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