From 309a679b052ff729f20f0a6a25b2ab3a6394c06a Mon Sep 17 00:00:00 2001 From: Erik Zhang Date: Fri, 9 Jan 2026 17:17:47 +0800 Subject: [PATCH 1/5] Add RoutingTable for DHT --- src/Neo/Network/P2P/Connection.cs | 11 + src/Neo/Network/P2P/KBucket.cs | 195 +++++++++++++++ src/Neo/Network/P2P/LocalNode.cs | 6 + src/Neo/Network/P2P/NodeContact.cs | 77 ++++++ .../Network/P2P/RemoteNode.ProtocolHandler.cs | 9 + src/Neo/Network/P2P/RemoteNode.cs | 10 + src/Neo/Network/P2P/RoutingTable.cs | 225 ++++++++++++++++++ src/Neo/UInt256.cs | 39 +++ 8 files changed, 572 insertions(+) create mode 100644 src/Neo/Network/P2P/KBucket.cs create mode 100644 src/Neo/Network/P2P/NodeContact.cs create mode 100644 src/Neo/Network/P2P/RoutingTable.cs diff --git a/src/Neo/Network/P2P/Connection.cs b/src/Neo/Network/P2P/Connection.cs index c213df60b6..41c16fb007 100644 --- a/src/Neo/Network/P2P/Connection.cs +++ b/src/Neo/Network/P2P/Connection.cs @@ -75,6 +75,7 @@ public void Disconnect(bool abort = false) { disconnected = true; tcp?.Tell(abort ? Tcp.Abort.Instance : Tcp.Close.Instance); + OnDisconnect(abort); 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. + /// true to indicate the disconnect is due to an abort operation; otherwise, false. + protected virtual void OnDisconnect(bool abort) + { + } + /// /// Called when data is received. /// diff --git a/src/Neo/Network/P2P/KBucket.cs b/src/Neo/Network/P2P/KBucket.cs new file mode 100644 index 0000000000..4262752201 --- /dev/null +++ b/src/Neo/Network/P2P/KBucket.cs @@ -0,0 +1,195 @@ +// 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 endpoints (promote the first src endpoint if present). + if (src.Endpoints.Count > 0) + dst.AddOrPromoteEndpoint(src.Endpoints[0]); + for (int i = 1; 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..d1ef29f4ee 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 diff --git a/src/Neo/Network/P2P/NodeContact.cs b/src/Neo/Network/P2P/NodeContact.cs new file mode 100644 index 0000000000..1841b5ff82 --- /dev/null +++ b/src/Neo/Network/P2P/NodeContact.cs @@ -0,0 +1,77 @@ +// 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. + +using System.Net; + +namespace Neo.Network.P2P; + +/// +/// Represents a reachability hint for a DHT node (NOT a live connection). +/// +public sealed class NodeContact +{ + /// + /// The verified DHT node identifier. + /// + public UInt256 NodeId { get; } + + /// + /// Known 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 network 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(IPEndPoint endpoint) + { + // Keep unique endpoints; promote to the front when we learn it's good. + int index = Endpoints.IndexOf(endpoint); + if (index == 0) return; + if (index > 0) Endpoints.RemoveAt(index); + Endpoints.Insert(0, endpoint); + } + + public override string ToString() + { + return $"{NodeId} ({(Endpoints.Count > 0 ? Endpoints[0].ToString() : "no-endpoint")})"; + } +} diff --git a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs index e96939e2a2..2b195c7aae 100644 --- a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs +++ b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs @@ -385,6 +385,15 @@ 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; + // Prefer the advertised TCP server endpoint (Listener) when available, otherwise fall back to the connected Remote endpoint. + var ep = ListenerTcpPort > 0 ? Listener : Remote; + _localNode.RoutingTable.Update(nodeId, ep); + _localNode.RoutingTable.MarkSuccess(nodeId); + CheckMessageQueue(); } diff --git a/src/Neo/Network/P2P/RemoteNode.cs b/src/Neo/Network/P2P/RemoteNode.cs index 63ce38008c..db7c35836c 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(bool abort) + { + if (abort) + { + // 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..5ff0fb220f --- /dev/null +++ b/src/Neo/Network/P2P/RoutingTable.cs @@ -0,0 +1,225 @@ +// 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 network 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, IPEndPoint 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.Count >= count) break; + if (!list.Contains(c)) list.Add(c); + } + } + 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/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 + }; + } } From 3ef1b409a7ca20785e9c6d2f1b733c33ba54f595 Mon Sep 17 00:00:00 2001 From: Alvaro Date: Sun, 11 Jan 2026 11:54:45 +0100 Subject: [PATCH 2/5] Add UTs for UInt256 XOR (#5) --- tests/Neo.UnitTests/UT_UInt256.cs | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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); + } } From 4f2f9f24a9543e237b48e6a02cd76580591c874f Mon Sep 17 00:00:00 2001 From: Erik Zhang Date: Wed, 14 Jan 2026 20:19:41 +0800 Subject: [PATCH 3/5] Add TransportProtocol and EndpointKind --- src/Neo/Network/P2P/EndpointKind.cs | 56 ++++++++++ src/Neo/Network/P2P/KBucket.cs | 6 +- src/Neo/Network/P2P/NodeContact.cs | 33 ++++-- src/Neo/Network/P2P/OverlayEndpoint.cs | 105 ++++++++++++++++++ .../Network/P2P/RemoteNode.ProtocolHandler.cs | 11 +- src/Neo/Network/P2P/RoutingTable.cs | 4 +- src/Neo/Network/P2P/TransportProtocol.cs | 20 ++++ 7 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 src/Neo/Network/P2P/EndpointKind.cs create mode 100644 src/Neo/Network/P2P/OverlayEndpoint.cs create mode 100644 src/Neo/Network/P2P/TransportProtocol.cs 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 index 4262752201..1753f75c90 100644 --- a/src/Neo/Network/P2P/KBucket.cs +++ b/src/Neo/Network/P2P/KBucket.cs @@ -169,10 +169,8 @@ void PromoteReplacementIfAny() static void Merge(NodeContact dst, NodeContact src) { - // Merge endpoints (promote the first src endpoint if present). - if (src.Endpoints.Count > 0) - dst.AddOrPromoteEndpoint(src.Endpoints[0]); - for (int i = 1; i < src.Endpoints.Count; i++) + // 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. diff --git a/src/Neo/Network/P2P/NodeContact.cs b/src/Neo/Network/P2P/NodeContact.cs index 1841b5ff82..0f562e79b3 100644 --- a/src/Neo/Network/P2P/NodeContact.cs +++ b/src/Neo/Network/P2P/NodeContact.cs @@ -9,8 +9,6 @@ // Redistribution and use in source and binary forms with or without // modifications are permitted. -using System.Net; - namespace Neo.Network.P2P; /// @@ -24,9 +22,9 @@ public sealed class NodeContact public UInt256 NodeId { get; } /// - /// Known endpoints for contacting the node. The first item is the preferred endpoint. + /// Known overlay endpoints for contacting the node. The first item is the preferred endpoint. /// - public List Endpoints { get; } = new(); + public List Endpoints { get; } = new(); /// /// Last time we successfully communicated with this node (handshake or DHT message). @@ -48,10 +46,9 @@ public sealed class NodeContact /// feature flags. /// /// The unique identifier for the node. This value is used to distinguish the node within the network. - /// A collection of network endpoints associated with the node. If not specified, the contact will have no initial - /// endpoints. + /// 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) + public NodeContact(UInt256 nodeId, IEnumerable? endpoints = null, ulong features = 0) { NodeId = nodeId; if (endpoints is not null) @@ -61,13 +58,25 @@ public NodeContact(UInt256 nodeId, IEnumerable? endpoints = null, ul Features = features; } - internal void AddOrPromoteEndpoint(IPEndPoint endpoint) + internal void AddOrPromoteEndpoint(OverlayEndpoint endpoint) { - // Keep unique endpoints; promote to the front when we learn it's good. + // Keep unique endpoints by (Transport, IP, Port); merge kinds when we learn new semantics. int index = Endpoints.IndexOf(endpoint); - if (index == 0) return; - if (index > 0) Endpoints.RemoveAt(index); - Endpoints.Insert(0, 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); + } } public override string ToString() 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 2b195c7aae..c76c17bd77 100644 --- a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs +++ b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs @@ -389,9 +389,14 @@ private void OnVerackMessageReceived() // 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; - // Prefer the advertised TCP server endpoint (Listener) when available, otherwise fall back to the connected Remote endpoint. - var ep = ListenerTcpPort > 0 ? Listener : Remote; - _localNode.RoutingTable.Update(nodeId, ep); + + // 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(); diff --git a/src/Neo/Network/P2P/RoutingTable.cs b/src/Neo/Network/P2P/RoutingTable.cs index 5ff0fb220f..a69bb9f5cb 100644 --- a/src/Neo/Network/P2P/RoutingTable.cs +++ b/src/Neo/Network/P2P/RoutingTable.cs @@ -58,10 +58,10 @@ public RoutingTable(UInt256 selfId, int bucketSize = 20, int replacementSize = 1 /// Adds or refreshes a contact in the routing table. /// /// The unique identifier of the node whose contact information is to be updated. - /// The network endpoint associated with the node. Must not be null. + /// 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, IPEndPoint endpoint, ulong features = 0) + public bool Update(UInt256 nodeId, OverlayEndpoint endpoint, ulong features = 0) { return Update(new(nodeId, [endpoint], features)); } 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 +} From aad52c7804b2856e078b1aae7f84002ee610188b Mon Sep 17 00:00:00 2001 From: Erik Zhang Date: Thu, 15 Jan 2026 00:14:41 +0800 Subject: [PATCH 4/5] MarkSuccess on Ping/Pong message received --- src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs index c76c17bd77..a350539560 100644 --- a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs +++ b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs @@ -373,12 +373,19 @@ 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() From f68d5fde894787e03f3150e7d9d3e1038c5566b9 Mon Sep 17 00:00:00 2001 From: Erik Zhang Date: Thu, 15 Jan 2026 00:53:39 +0800 Subject: [PATCH 5/5] Add DisconnectReason --- src/Neo/Network/P2P/Connection.cs | 22 +++++------ src/Neo/Network/P2P/DisconnectReason.cs | 39 +++++++++++++++++++ src/Neo/Network/P2P/LocalNode.cs | 19 +++++++-- .../Network/P2P/RemoteNode.ProtocolHandler.cs | 4 +- src/Neo/Network/P2P/RemoteNode.cs | 4 +- 5 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 src/Neo/Network/P2P/DisconnectReason.cs diff --git a/src/Neo/Network/P2P/Connection.cs b/src/Neo/Network/P2P/Connection.cs index 41c16fb007..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,12 +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); - OnDisconnect(abort); + tcp?.Tell(reason == DisconnectReason.Close ? Tcp.Close.Instance : Tcp.Abort.Instance); + OnDisconnect(reason); Context.Stop(Self); } @@ -91,8 +91,8 @@ protected virtual void OnAck() /// /// 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. - /// true to indicate the disconnect is due to an abort operation; otherwise, false. - protected virtual void OnDisconnect(bool abort) + /// The reason for the disconnection. + protected virtual void OnDisconnect(DisconnectReason reason) { } @@ -107,7 +107,7 @@ protected override void OnReceive(object message) switch (message) { case Close close: - Disconnect(close.Abort); + Disconnect(close.Reason); break; case Ack _: OnAck(); @@ -124,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/LocalNode.cs b/src/Neo/Network/P2P/LocalNode.cs index d1ef29f4ee..49618c8c92 100644 --- a/src/Neo/Network/P2P/LocalNode.cs +++ b/src/Neo/Network/P2P/LocalNode.cs @@ -166,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/RemoteNode.ProtocolHandler.cs b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs index a350539560..fda8265a39 100644 --- a/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs +++ b/src/Neo/Network/P2P/RemoteNode.ProtocolHandler.cs @@ -427,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 db7c35836c..4a52ad7299 100644 --- a/src/Neo/Network/P2P/RemoteNode.cs +++ b/src/Neo/Network/P2P/RemoteNode.cs @@ -137,9 +137,9 @@ protected override void OnAck() CheckMessageQueue(); } - protected override void OnDisconnect(bool abort) + protected override void OnDisconnect(DisconnectReason reason) { - if (abort) + if (reason != DisconnectReason.Close) { // DHT: connection dropped. Penalize the contact (do not immediately delete; allow churn). if (Version != null)