diff --git a/src/Neo/Network/P2P/Connection.cs b/src/Neo/Network/P2P/Connection.cs
index c213df60b6..81c9753ba2 100644
--- a/src/Neo/Network/P2P/Connection.cs
+++ b/src/Neo/Network/P2P/Connection.cs
@@ -21,7 +21,7 @@ namespace Neo.Network.P2P;
///
public abstract class Connection : UntypedActor
{
- internal class Close { public bool Abort; }
+ internal class Close { public DisconnectReason Reason; }
internal class Ack : Tcp.Event { public static Ack Instance = new(); }
///
@@ -58,7 +58,7 @@ protected Connection(object connection, IPEndPoint remote, IPEndPoint local)
{
Remote = remote;
Local = local;
- timer = Context.System.Scheduler.ScheduleTellOnceCancelable(TimeSpan.FromSeconds(connectionTimeoutLimitStart), Self, new Close { Abort = true }, ActorRefs.NoSender);
+ timer = Context.System.Scheduler.ScheduleTellOnceCancelable(TimeSpan.FromSeconds(connectionTimeoutLimitStart), Self, new Close { Reason = DisconnectReason.Timeout }, ActorRefs.NoSender);
switch (connection)
{
case IActorRef tcp:
@@ -70,11 +70,12 @@ protected Connection(object connection, IPEndPoint remote, IPEndPoint local)
///
/// Disconnect from the remote node.
///
- /// Indicates whether the TCP ABORT command should be sent.
- public void Disconnect(bool abort = false)
+ /// The reason for the disconnection.
+ public void Disconnect(DisconnectReason reason = DisconnectReason.Close)
{
disconnected = true;
- tcp?.Tell(abort ? Tcp.Abort.Instance : Tcp.Close.Instance);
+ tcp?.Tell(reason == DisconnectReason.Close ? Tcp.Close.Instance : Tcp.Abort.Instance);
+ OnDisconnect(reason);
Context.Stop(Self);
}
@@ -85,6 +86,16 @@ protected virtual void OnAck()
{
}
+ ///
+ /// Invoked when a disconnect operation occurs, allowing derived classes to handle cleanup or custom logic.
+ ///
+ /// Override this method in a derived class to implement custom behavior when a disconnect
+ /// occurs. This method is called regardless of whether the disconnect is graceful or due to an abort.
+ /// The reason for the disconnection.
+ protected virtual void OnDisconnect(DisconnectReason reason)
+ {
+ }
+
///
/// Called when data is received.
///
@@ -96,7 +107,7 @@ protected override void OnReceive(object message)
switch (message)
{
case Close close:
- Disconnect(close.Abort);
+ Disconnect(close.Reason);
break;
case Ack _:
OnAck();
@@ -113,8 +124,8 @@ protected override void OnReceive(object message)
private void OnReceived(ByteString data)
{
timer.CancelIfNotNull();
- timer = Context.System.Scheduler.ScheduleTellOnceCancelable(TimeSpan.FromSeconds(connectionTimeoutLimit), Self, new Close { Abort = true }, ActorRefs.NoSender);
- data.TryCatch(OnData, (_, _) => Disconnect(true));
+ timer = Context.System.Scheduler.ScheduleTellOnceCancelable(TimeSpan.FromSeconds(connectionTimeoutLimit), Self, new Close { Reason = DisconnectReason.Timeout }, ActorRefs.NoSender);
+ data.TryCatch(OnData, (_, _) => Disconnect(DisconnectReason.ProtocolViolation));
}
protected override void PostStop()
diff --git a/src/Neo/Network/P2P/DisconnectReason.cs b/src/Neo/Network/P2P/DisconnectReason.cs
new file mode 100644
index 0000000000..219df25683
--- /dev/null
+++ b/src/Neo/Network/P2P/DisconnectReason.cs
@@ -0,0 +1,39 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// DisconnectReason.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+namespace Neo.Network.P2P;
+
+///
+/// Specifies the reason for a disconnection event in a network or communication context.
+///
+/// Use this enumeration to determine why a connection was terminated.
+public enum DisconnectReason
+{
+ ///
+ /// No specific reason for disconnection.
+ ///
+ None,
+
+ ///
+ /// The connection was closed normally.
+ ///
+ Close,
+
+ ///
+ /// The connection was closed due to a timeout.
+ ///
+ Timeout,
+
+ ///
+ /// The connection was closed due to a protocol violation.
+ ///
+ ProtocolViolation
+}
diff --git a/src/Neo/Network/P2P/EndpointKind.cs b/src/Neo/Network/P2P/EndpointKind.cs
new file mode 100644
index 0000000000..d481cddd8d
--- /dev/null
+++ b/src/Neo/Network/P2P/EndpointKind.cs
@@ -0,0 +1,56 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// EndpointKind.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+namespace Neo.Network.P2P;
+
+///
+/// Describes how an overlay endpoint was learned and how it should be used.
+///
+[Flags]
+public enum EndpointKind : byte
+{
+ ///
+ /// The endpoint was observed as the remote endpoint of an incoming or
+ /// outgoing connection.
+ /// This usually represents a NAT-mapped or ephemeral public endpoint and
+ /// should NOT be treated as a reliable dial target.
+ ///
+ Observed = 1,
+
+ ///
+ /// The endpoint was explicitly advertised by the peer itself, typically
+ /// via protocol handshake metadata (e.g., Version/Listener port).
+ ///
+ /// Advertised endpoints indicate that the peer claims to be listening
+ /// for incoming connections on this address and port.
+ ///
+ /// Actual reachability is still validated through success/failure tracking.
+ ///
+ Advertised = 2,
+
+ ///
+ /// The endpoint was derived indirectly rather than directly observed or
+ /// self-advertised.
+ ///
+ /// Derived endpoints usually require validation before being trusted for
+ /// active communication.
+ ///
+ Derived = 4,
+
+ ///
+ /// The endpoint represents a relay or intermediary rather than a direct
+ /// network address of the target node.
+ ///
+ /// Relay endpoints are never used for direct dialing and require
+ /// protocol-specific relay support.
+ ///
+ Relay = 8
+}
diff --git a/src/Neo/Network/P2P/KBucket.cs b/src/Neo/Network/P2P/KBucket.cs
new file mode 100644
index 0000000000..1753f75c90
--- /dev/null
+++ b/src/Neo/Network/P2P/KBucket.cs
@@ -0,0 +1,193 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// KBucket.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Neo.Network.P2P;
+
+///
+/// A Kademlia-style k-bucket: stores up to contacts in LRU order.
+///
+sealed class KBucket
+{
+ private readonly LinkedList _lru = new();
+ private readonly Dictionary> _index = new();
+
+ // Replacement cache: best-effort candidates when the bucket is full.
+ private readonly LinkedList _replacements = new();
+ private readonly Dictionary> _repIndex = new();
+
+ public int Capacity { get; }
+ public int ReplacementCapacity { get; }
+ public int BadThreshold { get; }
+ public int Count => _lru.Count;
+ public IReadOnlyCollection Contacts => _lru;
+
+ public KBucket(int capacity, int replacementCapacity, int badThreshold)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
+ ArgumentOutOfRangeException.ThrowIfNegative(replacementCapacity);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(badThreshold);
+ Capacity = capacity;
+ ReplacementCapacity = replacementCapacity;
+ BadThreshold = badThreshold;
+ }
+
+ public bool TryGet(UInt256 nodeId, [NotNullWhen(true)] out NodeContact? contact)
+ {
+ if (_index.TryGetValue(nodeId, out var node))
+ {
+ contact = node.Value;
+ return true;
+ }
+ contact = null;
+ return false;
+ }
+
+ ///
+ /// Updates LRU position and contact metadata. If bucket is full and the node is new,
+ /// the node is placed into replacement cache.
+ ///
+ ///
+ /// True if the contact ended up in the main bucket; false if it was cached as a replacement.
+ ///
+ public bool Update(NodeContact incoming)
+ {
+ if (_index.TryGetValue(incoming.NodeId, out var existingNode))
+ {
+ Merge(existingNode.Value, incoming);
+ Touch(existingNode);
+ return true;
+ }
+
+ if (_lru.Count < Capacity)
+ {
+ var node = _lru.AddLast(incoming);
+ _index[incoming.NodeId] = node;
+ return true;
+ }
+
+ // Bucket full: keep as replacement candidate.
+ AddOrUpdateReplacement(incoming);
+ return false;
+ }
+
+ public void MarkSuccess(UInt256 nodeId)
+ {
+ if (_index.TryGetValue(nodeId, out var node))
+ {
+ node.Value.FailCount = 0;
+ node.Value.LastSeen = TimeProvider.Current.UtcNow;
+ Touch(node);
+ return;
+ }
+
+ // If it was only a replacement, promote its freshness.
+ if (_repIndex.TryGetValue(nodeId, out var repNode))
+ {
+ repNode.Value.FailCount = 0;
+ repNode.Value.LastSeen = TimeProvider.Current.UtcNow;
+ Touch(repNode);
+ }
+ }
+
+ public void MarkFailure(UInt256 nodeId)
+ {
+ if (_index.TryGetValue(nodeId, out var node))
+ {
+ node.Value.FailCount++;
+ if (node.Value.FailCount < BadThreshold) return;
+
+ // Evict bad node and promote best replacement (if any).
+ RemoveFrom(node, _index);
+ PromoteReplacementIfAny();
+ }
+ else if (_repIndex.TryGetValue(nodeId, out var repNode))
+ {
+ // If it is a replacement, decay it and possibly drop.
+ repNode.Value.FailCount++;
+ if (repNode.Value.FailCount >= BadThreshold)
+ RemoveFrom(repNode, _repIndex);
+ }
+ }
+
+ public void Remove(UInt256 nodeId)
+ {
+ if (_index.TryGetValue(nodeId, out var node))
+ {
+ RemoveFrom(node, _index);
+ PromoteReplacementIfAny();
+ }
+ else if (_repIndex.TryGetValue(nodeId, out var repNode))
+ {
+ RemoveFrom(repNode, _repIndex);
+ }
+ }
+
+ void AddOrUpdateReplacement(NodeContact incoming)
+ {
+ if (_repIndex.TryGetValue(incoming.NodeId, out var existing))
+ {
+ Merge(existing.Value, incoming);
+ Touch(existing);
+ return;
+ }
+
+ if (ReplacementCapacity == 0) return;
+
+ var node = _replacements.AddLast(incoming);
+ _repIndex[incoming.NodeId] = node;
+
+ if (_replacements.Count > ReplacementCapacity)
+ {
+ // Drop oldest replacement.
+ var first = _replacements.First;
+ if (first is not null)
+ RemoveFrom(first, _repIndex);
+ }
+ }
+
+ void PromoteReplacementIfAny()
+ {
+ if (_lru.Count >= Capacity) return;
+ if (_replacements.Last is null) return;
+
+ // Promote the most recently seen replacement.
+ var rep = _replacements.Last;
+ RemoveFrom(rep, _repIndex);
+ var main = _lru.AddLast(rep.Value);
+ _index[main.Value.NodeId] = main;
+ }
+
+ static void Merge(NodeContact dst, NodeContact src)
+ {
+ // Merge overlay endpoints (preserve transport; merge endpoint kinds).
+ for (int i = 0; i < src.Endpoints.Count; i++)
+ dst.AddOrPromoteEndpoint(src.Endpoints[i]);
+
+ // Prefer latest seen & features.
+ if (src.LastSeen > dst.LastSeen) dst.LastSeen = src.LastSeen;
+ dst.Features |= src.Features;
+ }
+
+ static void Touch(LinkedListNode node)
+ {
+ var list = node.List!;
+ list.Remove(node);
+ list.AddLast(node);
+ }
+
+ static void RemoveFrom(LinkedListNode node, Dictionary> index)
+ {
+ index.Remove(node.Value.NodeId);
+ node.List!.Remove(node);
+ }
+}
diff --git a/src/Neo/Network/P2P/LocalNode.cs b/src/Neo/Network/P2P/LocalNode.cs
index 210f1845e8..49618c8c92 100644
--- a/src/Neo/Network/P2P/LocalNode.cs
+++ b/src/Neo/Network/P2P/LocalNode.cs
@@ -75,6 +75,11 @@ public record GetInstance;
///
public UInt256 NodeId { get; }
+ ///
+ /// Routing table used by the DHT overlay network.
+ ///
+ public RoutingTable RoutingTable { get; }
+
///
/// The identifier of the client software of the local node.
///
@@ -95,6 +100,7 @@ public LocalNode(NeoSystem system, KeyPair nodeKey)
this.system = system;
NodeKey = nodeKey;
NodeId = nodeKey.PublicKey.GetNodeId(system.Settings);
+ RoutingTable = new RoutingTable(NodeId);
SeedList = new IPEndPoint[system.Settings.SeedList.Length];
// Start dns resolution in parallel
@@ -160,20 +166,33 @@ private static IPEndPoint GetIPEndpointFromHostPort(string hostNameOrAddress, in
///
/// Remote node actor.
/// Remote node object.
+ /// The reason for disconnection, if any.
/// if the new connection is allowed; otherwise, .
- public bool AllowNewConnection(IActorRef actor, RemoteNode node)
+ public bool AllowNewConnection(IActorRef actor, RemoteNode node, out DisconnectReason reason)
{
- if (node.Version!.Network != system.Settings.Network) return false;
- if (node.Version.NodeId == NodeId) return false;
+ if (node.Version!.Network != system.Settings.Network)
+ {
+ reason = DisconnectReason.ProtocolViolation;
+ return false;
+ }
+ if (node.Version.NodeId == NodeId)
+ {
+ reason = DisconnectReason.Close;
+ return false;
+ }
// filter duplicate connections
foreach (var other in RemoteNodes.Values)
if (other != node && other.Remote.Address.Equals(node.Remote.Address) && other.Version?.NodeId == node.Version.NodeId)
+ {
+ reason = DisconnectReason.Close;
return false;
+ }
if (node.Remote.Port != node.ListenerTcpPort && node.ListenerTcpPort != 0)
ConnectedPeers.TryUpdate(actor, node.Listener, node.Remote);
+ reason = DisconnectReason.None;
return true;
}
diff --git a/src/Neo/Network/P2P/NodeContact.cs b/src/Neo/Network/P2P/NodeContact.cs
new file mode 100644
index 0000000000..3ab2309295
--- /dev/null
+++ b/src/Neo/Network/P2P/NodeContact.cs
@@ -0,0 +1,117 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// NodeContact.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+namespace Neo.Network.P2P;
+
+///
+/// Represents a reachability hint for a DHT node (NOT a live connection).
+///
+public sealed class NodeContact
+{
+ const int MaxEndpoints = 4;
+
+ ///
+ /// The verified DHT node identifier.
+ ///
+ public UInt256 NodeId { get; }
+
+ ///
+ /// Known overlay endpoints for contacting the node. The first item is the preferred endpoint.
+ ///
+ public List Endpoints { get; } = new();
+
+ ///
+ /// Last time we successfully communicated with this node (handshake or DHT message).
+ ///
+ public DateTime LastSeen { get; internal set; }
+
+ ///
+ /// Consecutive failures when trying to contact this node.
+ ///
+ public int FailCount { get; internal set; }
+
+ ///
+ /// Optional capability flags (reserved).
+ ///
+ public ulong Features { get; internal set; }
+
+ ///
+ /// Initializes a new instance of the NodeContact class with the specified node identifier, optional endpoints, and
+ /// feature flags.
+ ///
+ /// The unique identifier for the node. This value is used to distinguish the node within the network.
+ /// A collection of overlay endpoints associated with the node. If not specified, the contact will have no initial endpoints.
+ /// A bit field representing the features supported by the node. The default is 0, indicating no features.
+ public NodeContact(UInt256 nodeId, IEnumerable? endpoints = null, ulong features = 0)
+ {
+ NodeId = nodeId;
+ if (endpoints is not null)
+ foreach (var ep in endpoints)
+ AddOrPromoteEndpoint(ep);
+ LastSeen = TimeProvider.Current.UtcNow;
+ Features = features;
+ }
+
+ internal void AddOrPromoteEndpoint(OverlayEndpoint endpoint)
+ {
+ // Keep unique endpoints by (Transport, IP, Port); merge kinds when we learn new semantics.
+ int index = Endpoints.IndexOf(endpoint);
+ if (index >= 0)
+ {
+ var merged = Endpoints[index].WithKind(Endpoints[index].Kind | endpoint.Kind);
+ if (index == 0)
+ {
+ Endpoints[0] = merged;
+ return;
+ }
+ Endpoints.RemoveAt(index);
+ Endpoints.Insert(0, merged);
+ }
+ else
+ {
+ Endpoints.Insert(0, endpoint);
+ TrimEndpoints();
+ }
+ }
+
+ public override string ToString()
+ {
+ return $"{NodeId} ({(Endpoints.Count > 0 ? Endpoints[0].ToString() : "no-endpoint")})";
+ }
+
+ void TrimEndpoints()
+ {
+ while (Endpoints.Count > MaxEndpoints)
+ {
+ int removeIndex = Endpoints.Count - 1;
+ int worstPriority = GetKindPriority(Endpoints[removeIndex].Kind);
+ for (int i = Endpoints.Count - 2; i >= 0; i--)
+ {
+ int priority = GetKindPriority(Endpoints[i].Kind);
+ if (priority < worstPriority)
+ {
+ worstPriority = priority;
+ removeIndex = i;
+ if (worstPriority == 0) break;
+ }
+ }
+ Endpoints.RemoveAt(removeIndex);
+ }
+ }
+
+ static int GetKindPriority(EndpointKind kind)
+ {
+ if (kind.HasFlag(EndpointKind.Advertised)) return 3;
+ if (kind.HasFlag(EndpointKind.Observed)) return 2;
+ if (kind.HasFlag(EndpointKind.Derived)) return 1;
+ return 0;
+ }
+}
diff --git a/src/Neo/Network/P2P/OverlayEndpoint.cs b/src/Neo/Network/P2P/OverlayEndpoint.cs
new file mode 100644
index 0000000000..8099669e8a
--- /dev/null
+++ b/src/Neo/Network/P2P/OverlayEndpoint.cs
@@ -0,0 +1,105 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// OverlayEndpoint.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+using System.Net;
+
+namespace Neo.Network.P2P;
+
+///
+/// An overlay-network endpoint with transport and discovery semantics.
+/// Equality intentionally ignores so that the same transport+ip+port
+/// can accumulate multiple kinds (e.g., Observed | Advertised).
+///
+public readonly struct OverlayEndpoint : IEquatable
+{
+ ///
+ /// The transport protocol used to communicate with this endpoint.
+ ///
+ public TransportProtocol Transport { get; }
+
+ ///
+ /// The IP endpoint of this overlay endpoint.
+ ///
+ public IPEndPoint EndPoint { get; }
+
+ ///
+ /// The kind of this overlay endpoint.
+ ///
+ public EndpointKind Kind { get; }
+
+ ///
+ /// Initializes a new instance of the OverlayEndpoint class with the specified transport protocol, network endpoint,
+ /// and endpoint kind.
+ ///
+ /// The transport protocol to use for the overlay endpoint.
+ /// The network endpoint associated with this overlay endpoint.
+ /// The kind of endpoint represented by this instance.
+ public OverlayEndpoint(TransportProtocol transport, IPEndPoint endPoint, EndpointKind kind)
+ {
+ Transport = transport;
+ EndPoint = endPoint;
+ Kind = kind;
+ }
+
+ ///
+ /// Returns a new OverlayEndpoint instance with the specified endpoint kind, preserving the existing transport and
+ /// endpoint values.
+ ///
+ /// The endpoint kind to associate with the new OverlayEndpoint instance.
+ /// A new OverlayEndpoint instance with the specified kind. The transport and endpoint values are copied from the
+ /// current instance.
+ public OverlayEndpoint WithKind(EndpointKind kind) => new(Transport, EndPoint, kind);
+
+ ///
+ /// Determines whether the current instance and the specified are equal.
+ ///
+ /// This method compares the Transport and EndPoint properties for equality. The Kind property is
+ /// not considered in the comparison.
+ /// The to compare with the current instance.
+ /// if the current instance and represent the same transport and
+ /// endpoint; otherwise, .
+ public bool Equals(OverlayEndpoint other)
+ {
+ // NOTE: ignore Kind on purpose
+ return Transport == other.Transport && EndPoint.Equals(other.EndPoint);
+ }
+
+ ///
+ /// Determines whether the specified object is equal to the current OverlayEndpoint instance.
+ ///
+ /// The object to compare with the current OverlayEndpoint instance.
+ /// true if the specified object is an OverlayEndpoint and is equal to the current instance; otherwise, false.
+ public override bool Equals(object? obj)
+ {
+ return obj is OverlayEndpoint other && Equals(other);
+ }
+
+ public override int GetHashCode()
+ {
+ // NOTE: ignore Kind on purpose
+ return HashCode.Combine(Transport, EndPoint);
+ }
+
+ public override string ToString()
+ {
+ return $"{Transport.ToString().ToLowerInvariant()}:{EndPoint}";
+ }
+
+ public static bool operator ==(OverlayEndpoint left, OverlayEndpoint right)
+ {
+ return left.Equals(right);
+ }
+
+ public static bool operator !=(OverlayEndpoint left, OverlayEndpoint right)
+ {
+ return !(left == right);
+ }
+}
diff --git a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs
index e96939e2a2..fda8265a39 100644
--- a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs
+++ b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs
@@ -373,18 +373,39 @@ private void OnMemPoolMessageReceived()
private void OnPingMessageReceived(PingPayload payload)
{
UpdateLastBlockIndex(payload.LastBlockIndex);
+
+ // Refresh routing table liveness on inbound Ping.
+ _localNode.RoutingTable.MarkSuccess(Version!.NodeId);
+
EnqueueMessage(Message.Create(MessageCommand.Pong, PingPayload.Create(NativeContract.Ledger.CurrentIndex(_system.StoreView), payload.Nonce)));
}
private void OnPongMessageReceived(PingPayload payload)
{
UpdateLastBlockIndex(payload.LastBlockIndex);
+
+ // DHT: Pong means our probe succeeded, strongly refresh liveness.
+ _localNode.RoutingTable.MarkSuccess(Version!.NodeId);
}
private void OnVerackMessageReceived()
{
_verack = true;
_system.TaskManager.Tell(new TaskManager.Register(Version!));
+
+ // DHT: a verack means the handshake is complete and the remote identity (NodeId) has been verified.
+ // Feed the remote contact into the local RoutingTable.
+ var nodeId = Version!.NodeId;
+
+ // Record both:
+ // - Observed endpoint: what we actually connected to (may be NAT-mapped; not necessarily dialable)
+ // - Advertised endpoint: what the peer claims to be listening on (dialable candidate)
+ _localNode.RoutingTable.Update(nodeId, new OverlayEndpoint(TransportProtocol.Tcp, Remote, EndpointKind.Observed));
+ if (ListenerTcpPort > 0)
+ _localNode.RoutingTable.Update(nodeId, new OverlayEndpoint(TransportProtocol.Tcp, Listener, EndpointKind.Advertised));
+
+ _localNode.RoutingTable.MarkSuccess(nodeId);
+
CheckMessageQueue();
}
@@ -406,9 +427,9 @@ private void OnVersionMessageReceived(VersionPayload payload)
break;
}
}
- if (!_localNode.AllowNewConnection(Self, this))
+ if (!_localNode.AllowNewConnection(Self, this, out DisconnectReason reason))
{
- Disconnect(true);
+ Disconnect(reason);
return;
}
SendMessage(Message.Create(MessageCommand.Verack));
diff --git a/src/Neo/Network/P2P/RemoteNode.cs b/src/Neo/Network/P2P/RemoteNode.cs
index 63ce38008c..4a52ad7299 100644
--- a/src/Neo/Network/P2P/RemoteNode.cs
+++ b/src/Neo/Network/P2P/RemoteNode.cs
@@ -137,6 +137,16 @@ protected override void OnAck()
CheckMessageQueue();
}
+ protected override void OnDisconnect(DisconnectReason reason)
+ {
+ if (reason != DisconnectReason.Close)
+ {
+ // DHT: connection dropped. Penalize the contact (do not immediately delete; allow churn).
+ if (Version != null)
+ _localNode.RoutingTable.MarkFailure(Version.NodeId);
+ }
+ }
+
protected override void OnData(ByteString data)
{
_messageBuffer = _messageBuffer.Concat(data);
diff --git a/src/Neo/Network/P2P/RoutingTable.cs b/src/Neo/Network/P2P/RoutingTable.cs
new file mode 100644
index 0000000000..0b0b50b933
--- /dev/null
+++ b/src/Neo/Network/P2P/RoutingTable.cs
@@ -0,0 +1,228 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// RoutingTable.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+using System.Net;
+
+namespace Neo.Network.P2P;
+
+///
+/// Kademlia-style routing table built from 256 k-buckets.
+///
+public sealed class RoutingTable
+{
+ const int IdBits = UInt256.Length * 8;
+
+ readonly UInt256 _selfId;
+ readonly KBucket[] _buckets;
+
+ ///
+ /// Max contacts per bucket (K in Kademlia).
+ ///
+ public int BucketSize { get; }
+
+ ///
+ /// Initializes a new instance of the RoutingTable class with the specified node identifier and bucket configuration
+ /// parameters.
+ ///
+ /// The routing table is organized into buckets based on the distance from the local node
+ /// identifier. Adjusting bucketSize, replacementSize, or badThreshold can affect the table's resilience and
+ /// performance in peer-to-peer network scenarios.
+ /// The unique identifier of the local node for which the routing table is constructed.
+ /// The maximum number of entries allowed in each bucket. Must be positive.
+ /// The maximum number of replacement entries maintained for each bucket. Must be non-negative.
+ /// The number of failed contact attempts after which a node is considered bad and eligible for replacement. Must be
+ /// positive.
+ public RoutingTable(UInt256 selfId, int bucketSize = 20, int replacementSize = 10, int badThreshold = 3)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bucketSize);
+ ArgumentOutOfRangeException.ThrowIfNegative(replacementSize);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(badThreshold);
+
+ _selfId = selfId;
+ BucketSize = bucketSize;
+
+ _buckets = new KBucket[IdBits];
+ for (int i = 0; i < _buckets.Length; i++)
+ _buckets[i] = new KBucket(bucketSize, replacementSize, badThreshold);
+ }
+
+ ///
+ /// Adds or refreshes a contact in the routing table.
+ ///
+ /// The unique identifier of the node whose contact information is to be updated.
+ /// The overlay endpoint associated with the node. Must not be null.
+ /// An optional set of feature flags describing the node's capabilities. The default is 0, indicating no features.
+ /// true if the node contact information was updated successfully; otherwise, false.
+ public bool Update(UInt256 nodeId, OverlayEndpoint endpoint, ulong features = 0)
+ {
+ return Update(new(nodeId, [endpoint], features));
+ }
+
+ ///
+ /// Adds or refreshes a contact in the routing table.
+ ///
+ /// If the specified contact represents the local node, the update is ignored and the method
+ /// returns false.
+ /// The contact information for the node to update. Must not represent the local node.
+ /// true if the contact was successfully updated; otherwise, false.
+ public bool Update(NodeContact contact)
+ {
+ int bucket = GetBucketIndex(contact.NodeId);
+ if (bucket < 0) return false; // ignore self
+ lock (_buckets[bucket])
+ return _buckets[bucket].Update(contact);
+ }
+
+ ///
+ /// Marks a contact as recently successful (e.g., handshake OK, DHT request succeeded).
+ ///
+ public void MarkSuccess(UInt256 nodeId)
+ {
+ int bucket = GetBucketIndex(nodeId);
+ if (bucket < 0) return;
+ lock (_buckets[bucket])
+ _buckets[bucket].MarkSuccess(nodeId);
+ }
+
+ ///
+ /// Marks a contact as failed (e.g., connection timeout). May evict it if it becomes bad.
+ ///
+ public void MarkFailure(UInt256 nodeId)
+ {
+ int bucket = GetBucketIndex(nodeId);
+ if (bucket < 0) return;
+ lock (_buckets[bucket])
+ _buckets[bucket].MarkFailure(nodeId);
+ }
+
+ ///
+ /// Removes a contact from the routing table.
+ ///
+ public void Remove(UInt256 nodeId)
+ {
+ int bucket = GetBucketIndex(nodeId);
+ if (bucket < 0) return;
+ lock (_buckets[bucket])
+ _buckets[bucket].Remove(nodeId);
+ }
+
+ ///
+ /// Returns up to contacts closest to .
+ ///
+ public IReadOnlyList FindClosest(UInt256 targetId, int count)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(count);
+ if (count == 0) return Array.Empty();
+
+ // Start from the bucket corresponding to target distance, then expand outward.
+ int start = GetBucketIndex(targetId);
+ if (start < 0) start = 0;
+
+ var candidates = new List(Math.Min(count * 3, BucketSize * 8));
+ CollectFromBuckets(start, candidates, hardLimit: Math.Max(count * 8, BucketSize * 8));
+
+ // Sort by XOR distance to target.
+ candidates.Sort((a, b) => CompareDistance(a.NodeId, b.NodeId, targetId));
+
+ if (candidates.Count <= count) return candidates;
+ return candidates.GetRange(0, count);
+ }
+
+ ///
+ /// Returns a sample of contacts across buckets (useful for bootstrap / gossip / health checks).
+ ///
+ public IReadOnlyList Sample(int count)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(count);
+ if (count == 0) return Array.Empty();
+
+ var list = new List(count);
+ // Prefer spread: take one from each bucket, round-robin.
+ int index = 0;
+ while (list.Count < count && index < IdBits)
+ {
+ lock (_buckets[index])
+ {
+ var bucket = _buckets[index].Contacts;
+ if (bucket.Count > 0)
+ {
+ // take most recently seen (tail)
+ list.Add(bucket.Last());
+ }
+ }
+ index++;
+ }
+
+ // If still short, just flatten and take.
+ if (list.Count < count)
+ {
+ foreach (var c in EnumerateAllContacts())
+ {
+ if (!list.Contains(c))
+ {
+ list.Add(c);
+ if (list.Count >= count) break;
+ }
+ }
+ }
+ return list;
+ }
+
+ void CollectFromBuckets(int start, List output, int hardLimit)
+ {
+ void AddRange(int bucketIndex)
+ {
+ lock (_buckets[bucketIndex])
+ foreach (var c in _buckets[bucketIndex].Contacts)
+ {
+ output.Add(c);
+ if (output.Count >= hardLimit) return;
+ }
+ }
+
+ AddRange(start);
+ if (output.Count >= hardLimit) return;
+
+ for (int step = 1; step < IdBits; step++)
+ {
+ int left = start - step;
+ int right = start + step;
+
+ if (left >= 0) AddRange(left);
+ if (output.Count >= hardLimit) break;
+ if (right < IdBits) AddRange(right);
+ if (output.Count >= hardLimit) break;
+ if (left < 0 && right >= IdBits) break;
+ }
+ }
+
+ IEnumerable EnumerateAllContacts()
+ {
+ for (int i = 0; i < IdBits; i++)
+ lock (_buckets[i])
+ foreach (var c in _buckets[i].Contacts)
+ yield return c;
+ }
+
+ int GetBucketIndex(UInt256 nodeId)
+ {
+ if (nodeId == _selfId) return -1;
+ int msb = (_selfId ^ nodeId).MostSignificantBit;
+ return msb; // -1..255
+ }
+
+ static int CompareDistance(UInt256 a, UInt256 b, UInt256 target)
+ {
+ var da = a ^ target;
+ var db = b ^ target;
+ return da.CompareTo(db);
+ }
+}
diff --git a/src/Neo/Network/P2P/TransportProtocol.cs b/src/Neo/Network/P2P/TransportProtocol.cs
new file mode 100644
index 0000000000..4d120dbfd1
--- /dev/null
+++ b/src/Neo/Network/P2P/TransportProtocol.cs
@@ -0,0 +1,20 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// TransportProtocol.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+namespace Neo.Network.P2P;
+
+///
+/// Transport protocol for an overlay endpoint.
+///
+public enum TransportProtocol : byte
+{
+ Tcp
+}
diff --git a/src/Neo/UInt256.cs b/src/Neo/UInt256.cs
index f50097c2c6..8cea5aab30 100644
--- a/src/Neo/UInt256.cs
+++ b/src/Neo/UInt256.cs
@@ -12,6 +12,7 @@
using Neo.IO;
using System.Buffers.Binary;
using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -40,6 +41,27 @@ public class UInt256 : IComparable, IComparable, IEquatable, I
public int Size => Length;
+ ///
+ /// Gets the index of the most significant set bit in the current value.
+ ///
+ /// The most significant bit is the highest-order bit that is set to 1. If no bits are set, the
+ /// property returns -1.
+ public int MostSignificantBit
+ {
+ get
+ {
+ if (_value4 != 0)
+ return 192 + BitOperations.Log2(_value4);
+ if (_value3 != 0)
+ return 128 + BitOperations.Log2(_value3);
+ if (_value2 != 0)
+ return 64 + BitOperations.Log2(_value2);
+ if (_value1 != 0)
+ return BitOperations.Log2(_value1);
+ return -1;
+ }
+ }
+
///
/// Initializes a new instance of the class.
///
@@ -252,4 +274,21 @@ public static implicit operator UInt256(byte[] b)
{
return left.CompareTo(right) <= 0;
}
+
+ ///
+ /// Performs a bitwise XOR operation on two values.
+ ///
+ /// The first operand.
+ /// The second operand.
+ /// The result of the bitwise XOR operation.
+ public static UInt256 operator ^(UInt256 left, UInt256 right)
+ {
+ return new UInt256
+ {
+ _value1 = left._value1 ^ right._value1,
+ _value2 = left._value2 ^ right._value2,
+ _value3 = left._value3 ^ right._value3,
+ _value4 = left._value4 ^ right._value4
+ };
+ }
}
diff --git a/tests/Neo.UnitTests/UT_UInt256.cs b/tests/Neo.UnitTests/UT_UInt256.cs
index dd6536ab4e..9c12ce3d6b 100644
--- a/tests/Neo.UnitTests/UT_UInt256.cs
+++ b/tests/Neo.UnitTests/UT_UInt256.cs
@@ -208,4 +208,57 @@ public void TestSpanAndSerializeLittleEndian()
Assert.ThrowsExactly(() => value.Serialize(shortBuffer.AsSpan()));
Assert.ThrowsExactly(() => value.SafeSerialize(shortBuffer.AsSpan()));
}
+
+ [TestMethod]
+ public void TestXorWithZeroIsIdentity()
+ {
+ var a = CreateSequential(0x10);
+ Assert.AreEqual(a, a ^ UInt256.Zero);
+ Assert.AreEqual(a, UInt256.Zero ^ a);
+ }
+
+ [TestMethod]
+ public void TestXorWithSelfIsZero()
+ {
+ var a = CreateSequential(0x42);
+ Assert.AreEqual(UInt256.Zero, a ^ a);
+ }
+
+ [TestMethod]
+ public void TestXorAssociative()
+ {
+ var a = CreateSequential(0x10);
+ var b = CreateSequential(0x20);
+ var c = CreateSequential(0x30);
+ var left = (a ^ b) ^ c;
+ var right = a ^ (b ^ c);
+
+ Assert.AreEqual(left, right);
+ }
+
+ [TestMethod]
+ public void TestXorCommutativeAndMatchesManual()
+ {
+ var a = CreateSequential(0x00);
+ var b = CreateSequential(0xF0);
+
+ var ab = a.ToArray();
+ var bb = b.ToArray();
+
+ var rb = new byte[UInt256.Length];
+ for (int i = 0; i < rb.Length; i++)
+ rb[i] = (byte)(ab[i] ^ bb[i]);
+
+ var expectedValue = new UInt256(rb);
+ Assert.AreEqual(expectedValue, a ^ b);
+ Assert.AreEqual(expectedValue, b ^ a);
+ }
+
+ private static UInt256 CreateSequential(byte start)
+ {
+ var bytes = new byte[UInt256.Length];
+ for (var i = 0; i < bytes.Length; i++)
+ bytes[i] = unchecked((byte)(start + i));
+ return new UInt256(bytes);
+ }
}