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 + }; + } } 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); + } }