diff --git a/src/Neo.Configuration/BlockchainBackupOptions.cs b/src/Neo.Configuration/BlockchainBackupOptions.cs
index b67837a..06cd81d 100644
--- a/src/Neo.Configuration/BlockchainBackupOptions.cs
+++ b/src/Neo.Configuration/BlockchainBackupOptions.cs
@@ -20,12 +20,13 @@
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES
+using Neo.Configuration.Json;
using System;
using System.IO;
namespace Neo.Configuration
{
- public class BlockchainBackupOptions
+ public class BlockchainBackupOptions : JsonModel
{
public string BackupPath { get; init; } = Path.Combine(AppContext.BaseDirectory, "data", "backups");
diff --git a/src/Neo.Core/Blockchain/InventoryType.cs b/src/Neo.Core/Blockchain/InventoryType.cs
index e8fb483..da3f085 100644
--- a/src/Neo.Core/Blockchain/InventoryType.cs
+++ b/src/Neo.Core/Blockchain/InventoryType.cs
@@ -32,16 +32,16 @@ public enum InventoryType : byte
///
/// Indicates that the inventory is a transaction.
///
- TX = MessageCommandType.Transaction,
+ TX = ProtocolMessageCommand.Transaction,
///
/// Indicates that the inventory is a block.
///
- Block = MessageCommandType.Block,
+ Block = ProtocolMessageCommand.Block,
///
/// Indicates that the inventory is an extensible payload.
///
- Extensible = MessageCommandType.Extensible
+ Extensible = ProtocolMessageCommand.Extensible
}
}
diff --git a/src/Neo.Core/CoreUtilities.cs b/src/Neo.Core/CoreUtilities.cs
index 6a062c6..5e94015 100644
--- a/src/Neo.Core/CoreUtilities.cs
+++ b/src/Neo.Core/CoreUtilities.cs
@@ -37,9 +37,20 @@ public static long ToUnixTimeMilliseconds(TimeSpan timeSpan, TimeSpan offset = d
new DateTimeOffset(timeSpan.Ticks, offset)
.ToUnixTimeMilliseconds();
+ public static uint ToUnixTimeSeconds(DateTime dateTime, TimeSpan offset = default) =>
+ (uint)new DateTimeOffset(dateTime, offset)
+ .ToUnixTimeSeconds();
+
+ public static uint ToUnixTimeSeconds(TimeSpan timeSpan, TimeSpan offset = default) =>
+ (uint)new DateTimeOffset(timeSpan.Ticks, offset)
+ .ToUnixTimeSeconds();
+
public static DateTime FromUnixTimeMilliseconds(ulong milliseconds, bool isLocalDateTime = false) =>
FromUnixTimeMilliseconds((long)milliseconds, isLocalDateTime);
+ public static DateTime FromUnixTimeSeconds(uint seconds, bool isLocalDateTime = false) =>
+ FromUnixTimeMilliseconds(seconds * TimeSpan.MillisecondsPerSecond, isLocalDateTime);
+
public static DateTime FromUnixTimeMilliseconds(long milliseconds, bool isLocalDateTime = false)
{
var dateTimeOffset = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
diff --git a/src/Neo.Core/Cryptography/ECC/ECCurve.cs b/src/Neo.Core/Cryptography/ECC/ECCurve.cs
index cda7b02..f4692a9 100644
--- a/src/Neo.Core/Cryptography/ECC/ECCurve.cs
+++ b/src/Neo.Core/Cryptography/ECC/ECCurve.cs
@@ -59,7 +59,7 @@ private ECCurve(BigInteger p, BigInteger b, ECCurveName curve)
};
}
- internal byte[] CompressPoint(ReadOnlySpan span)
+ internal static byte[] CompressPoint(ReadOnlySpan span)
{
var prefix = span[^1] % 2 == 0 ?
(byte)0x02 : (byte)0x03;
diff --git a/src/Neo.Core/Cryptography/ECC/ECPoint.cs b/src/Neo.Core/Cryptography/ECC/ECPoint.cs
index 7a611ca..a77c2a9 100644
--- a/src/Neo.Core/Cryptography/ECC/ECPoint.cs
+++ b/src/Neo.Core/Cryptography/ECC/ECPoint.cs
@@ -196,7 +196,7 @@ public void Deserialize(Stream reader)
public byte[] Encode(bool shouldCompress = true)
{
return shouldCompress ?
- _curve.CompressPoint(_uncompressed.AsSpan()) :
+ ECCurve.CompressPoint(_uncompressed.AsSpan()) :
_uncompressed;
}
diff --git a/src/Neo.Core/Cryptography/ProtectedString.cs b/src/Neo.Core/Cryptography/ProtectedString.cs
index cd3de96..dca2459 100644
--- a/src/Neo.Core/Cryptography/ProtectedString.cs
+++ b/src/Neo.Core/Cryptography/ProtectedString.cs
@@ -130,8 +130,8 @@ public static string UnprotectData(ReadOnlySpan protectedData)
using var aes = new AesGcm(s_entropy, TagSize);
- var nonceBytes = protectedData.Slice(0, NonceSize);
- var tagBytes = protectedData.Slice(protectedData.Length - TagSize);
+ var nonceBytes = protectedData[..NonceSize];
+ var tagBytes = protectedData[^TagSize..];
var ciphertext = protectedData.Slice(NonceSize, protectedData.Length - NonceSize - TagSize);
Span plaintext = stackalloc byte[ciphertext.Length];
diff --git a/src/Neo.Core/Cryptography/RipeMD160.cs b/src/Neo.Core/Cryptography/RipeMD160.cs
index f25055b..d0ed9c7 100644
--- a/src/Neo.Core/Cryptography/RipeMD160.cs
+++ b/src/Neo.Core/Cryptography/RipeMD160.cs
@@ -44,7 +44,10 @@ private RipeMD160()
public static byte[] HashData(byte[] data)
{
using var hasher = new RipeMD160();
+ // CA1850 wants HashData over ComputeHash, but this method *is* the static HashData entry point.
+#pragma warning disable CA1850
return hasher.ComputeHash(data);
+#pragma warning restore CA1850
}
public override void Initialize()
diff --git a/src/Neo.Core/Cryptography/SCrypt.cs b/src/Neo.Core/Cryptography/SCrypt.cs
index db79a3c..e5dc78a 100644
--- a/src/Neo.Core/Cryptography/SCrypt.cs
+++ b/src/Neo.Core/Cryptography/SCrypt.cs
@@ -46,10 +46,10 @@ public static byte[] Generate(byte[] password, byte[] salt, int n, int r, int p,
///
/// Convenience overload for string password.
///
- public static byte[] Generate(string password, byte[] salt, int N = 16384, int r = 8, int p = 1, int keyLength = 32)
+ public static byte[] Generate(string password, byte[] salt, int n = 16384, int r = 8, int p = 1, int keyLength = 32)
{
var passBytes = CoreUtilities.StrictUtf8Encoding.GetBytes(password);
- return Generate(passBytes, salt, N, r, p, keyLength);
+ return Generate(passBytes, salt, n, r, p, keyLength);
}
///
@@ -77,12 +77,12 @@ public unsafe static byte[] CryptoScrypt(byte[] password, byte[] salt, int n, in
/* 1: (B_0 ... B_{p-1}) <-- PBKDF2(P, S, 1, p * 128 * r) */
PBKDF2_SHA256(mac, password, salt, salt.Length, 1, ba, p * 128 * r);
- fixed (byte* B = ba)
- fixed (void* V = va)
- fixed (void* XY = xYa)
+ fixed (byte* b = ba)
+ fixed (void* v = va)
+ fixed (void* xy = xYa)
{
for (var i = 0; i < p; i++)
- SMix(&B[i * 128 * r], r, n, (uint*)V, (uint*)XY);
+ SMix(&b[i * 128 * r], r, n, (uint*)v, (uint*)xy);
}
/* 5: DK <-- PBKDF2(P, B, 1, dkLen) */
@@ -96,8 +96,8 @@ private static void PBKDF2_SHA256(HMACSHA256 mac, byte[] password, byte[] salt,
if (derivedKeyLength > (Math.Pow(2, 32) - 1) * 32)
throw new ArgumentException("Requested key length too long");
- var U = new byte[32];
- var T = new byte[32];
+ var u = new byte[32];
+ var t = new byte[32];
var saltBuffer = new byte[saltLength + 4];
var blockCount = (int)Math.Ceiling(derivedKeyLength / 32d);
@@ -114,20 +114,20 @@ private static void PBKDF2_SHA256(HMACSHA256 mac, byte[] password, byte[] salt,
mac.Initialize();
mac.TransformFinalBlock(saltBuffer, 0, saltBuffer.Length);
- Buffer.BlockCopy(mac.Hash!, 0, U, 0, U.Length);
+ Buffer.BlockCopy(mac.Hash!, 0, u, 0, u.Length);
- Buffer.BlockCopy(U, 0, T, 0, 32);
+ Buffer.BlockCopy(u, 0, t, 0, 32);
for (long j = 1; j < iterationCount; j++)
{
- mac.TransformFinalBlock(U, 0, U.Length);
- Buffer.BlockCopy(mac.Hash!, 0, U, 0, U.Length);
+ mac.TransformFinalBlock(u, 0, u.Length);
+ Buffer.BlockCopy(mac.Hash!, 0, u, 0, u.Length);
for (var k = 0; k < 32; k++)
- T[k] ^= U[k];
+ t[k] ^= u[k];
}
- Buffer.BlockCopy(T, 0, derivedKey, (i - 1) * 32, (i == blockCount ? r : 32));
+ Buffer.BlockCopy(t, 0, derivedKey, (i - 1) * 32, (i == blockCount ? r : 32));
}
}
@@ -164,12 +164,12 @@ private unsafe static uint Decode32(byte* p)
return p[0] | ((uint)p[1] << 8) | ((uint)p[2] << 16) | ((uint)p[3] << 24);
}
- private unsafe static void Salsa208(uint* B)
+ private unsafe static void Salsa208(uint* b)
{
- uint x0 = B[0], x1 = B[1], x2 = B[2], x3 = B[3],
- x4 = B[4], x5 = B[5], x6 = B[6], x7 = B[7],
- x8 = B[8], x9 = B[9], x10 = B[10], x11 = B[11],
- x12 = B[12], x13 = B[13], x14 = B[14], x15 = B[15];
+ uint x0 = b[0], x1 = b[1], x2 = b[2], x3 = b[3],
+ x4 = b[4], x5 = b[5], x6 = b[6], x7 = b[7],
+ x8 = b[8], x9 = b[9], x10 = b[10], x11 = b[11],
+ x12 = b[12], x13 = b[13], x14 = b[14], x15 = b[15];
for (var i = 0; i < 8; i += 2)
{
@@ -184,10 +184,10 @@ private unsafe static void Salsa208(uint* B)
x12 ^= R(x15 + x14, 7); x13 ^= R(x12 + x15, 9); x14 ^= R(x13 + x12, 13); x15 ^= R(x14 + x13, 18);
}
- B[0] += x0; B[1] += x1; B[2] += x2; B[3] += x3;
- B[4] += x4; B[5] += x5; B[6] += x6; B[7] += x7;
- B[8] += x8; B[9] += x9; B[10] += x10; B[11] += x11;
- B[12] += x12; B[13] += x13; B[14] += x14; B[15] += x15;
+ b[0] += x0; b[1] += x1; b[2] += x2; b[3] += x3;
+ b[4] += x4; b[5] += x5; b[6] += x6; b[7] += x7;
+ b[8] += x8; b[9] += x9; b[10] += x10; b[11] += x11;
+ b[12] += x12; b[13] += x13; b[14] += x14; b[15] += x15;
}
private static uint R(uint a, int b) =>
@@ -209,9 +209,9 @@ private unsafe static void BlockMix(uint* bin, uint* bOut, uint* x, int r)
}
}
- private unsafe static long Integerify(uint* B, int r)
+ private unsafe static long Integerify(uint* b, int r)
{
- var x = (uint*)(((byte*)B) + (2 * r - 1) * 64);
+ var x = (uint*)(((byte*)b) + (2 * r - 1) * 64);
return (((long)x[1] << 32) + x[0]);
}
diff --git a/src/Neo.Core/Extensions/ByteExtensions.cs b/src/Neo.Core/Extensions/ByteExtensions.cs
index 5264028..ffd404d 100644
--- a/src/Neo.Core/Extensions/ByteExtensions.cs
+++ b/src/Neo.Core/Extensions/ByteExtensions.cs
@@ -107,6 +107,5 @@ public static int ToHashCode(this byte[] data, int seed = 397) =>
(hash, b) =>
unchecked((hash * 31) ^ b)
);
-
}
}
diff --git a/src/Neo.Core/Extensions/INeoSerializableExtensions.cs b/src/Neo.Core/Extensions/INeoSerializableExtensions.cs
index 9c61bcc..2ce42bb 100644
--- a/src/Neo.Core/Extensions/INeoSerializableExtensions.cs
+++ b/src/Neo.Core/Extensions/INeoSerializableExtensions.cs
@@ -43,6 +43,6 @@ public static byte[] ToArray(this INeoSerializable source)
public static int GetSerializedSize(this INeoSerializable[] source) =>
source.Length.GetCompactSize() +
- (source.Length * source.Sum(s => s.Size));
+ source.Sum(static s => s.Size);
}
}
diff --git a/src/Neo.Core/Extensions/MemoryExtensions.cs b/src/Neo.Core/Extensions/MemoryExtensions.cs
index a0e1379..e924f46 100644
--- a/src/Neo.Core/Extensions/MemoryExtensions.cs
+++ b/src/Neo.Core/Extensions/MemoryExtensions.cs
@@ -78,6 +78,12 @@ public static TAccumulate Aggregate(this ReadOnlyMemory data, int seed) =>
+ data.Aggregate(seed,
+ (hash, b) =>
+ unchecked((hash * 31) ^ b)
+ );
+
public static int ToHashCode(this Memory data) =>
data.Aggregate(data.Length,
(hash, b) =>
diff --git a/src/Neo.Core/Extensions/SpanExtensions.cs b/src/Neo.Core/Extensions/SpanExtensions.cs
index bcc8f8f..6d24ebe 100644
--- a/src/Neo.Core/Extensions/SpanExtensions.cs
+++ b/src/Neo.Core/Extensions/SpanExtensions.cs
@@ -20,8 +20,10 @@
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES
+using K4os.Compression.LZ4;
using Neo.Core.Serialization;
using System;
+using System.Buffers.Binary;
using System.Runtime.CompilerServices;
namespace Neo.Core.Extensions
@@ -78,16 +80,34 @@ public static TAccumulate Aggregate(this ReadOnlySpan data, int seed = 397) =>
- data.Aggregate(seed,
- (hash, b) =>
- unchecked((hash * 31) ^ b)
- );
-
public static int ToHashCode(this ReadOnlySpan data, int seed = 397) =>
data.Aggregate(seed,
(hash, b) =>
unchecked((hash * 31) ^ b)
);
+
+ public static Span ToLz4Compress(this ReadOnlySpan data)
+ {
+ var maxLength = LZ4Codec.MaximumOutputSize(data.Length);
+ Span buffer = new byte[sizeof(uint) + maxLength];
+
+ BinaryPrimitives.WriteInt32LittleEndian(buffer, data.Length);
+
+ var length = LZ4Codec.Encode(data, buffer[sizeof(uint)..]);
+
+ return buffer[..(sizeof(uint) + length)];
+ }
+
+ public static Span ToLz4Decompress(this ReadOnlySpan data)
+ {
+ var length = BinaryPrimitives.ReadInt32LittleEndian(data);
+ Span result = new byte[length];
+ var decodedBytes = LZ4Codec.Decode(data[4..], result);
+
+ if (decodedBytes != length)
+ throw new FormatException($"Length \'{length}\' does not match the decompressed data length \'{decodedBytes}\'.");
+
+ return result;
+ }
}
}
diff --git a/src/Neo.Core/Extensions/StreamExtensions.cs b/src/Neo.Core/Extensions/StreamExtensions.cs
index ca46c02..6da5a33 100644
--- a/src/Neo.Core/Extensions/StreamExtensions.cs
+++ b/src/Neo.Core/Extensions/StreamExtensions.cs
@@ -156,7 +156,8 @@ public static void WriteObjects(this Stream stream, T[] values)
t.Serialize(stream);
}
- public static T ReadCompact(this Stream stream) where T : unmanaged, IBinaryInteger
+ public static T ReadCompact(this Stream stream)
+ where T : unmanaged, IBinaryInteger
{
if (stream.CanRead == false)
throw new NotSupportedException("Stream does not support reading.");
diff --git a/src/Neo.Core/Extensions/StringExtensions.cs b/src/Neo.Core/Extensions/StringExtensions.cs
index 4d84faf..2d6a531 100644
--- a/src/Neo.Core/Extensions/StringExtensions.cs
+++ b/src/Neo.Core/Extensions/StringExtensions.cs
@@ -71,5 +71,11 @@ public static UInt160 ToScriptHash(this string address, byte version)
return new(data.AsSpan(1));
}
+
+ public static int GetSerializedSize(this string source)
+ {
+ var byteCount = CoreUtilities.StrictUtf8Encoding.GetByteCount(source);
+ return byteCount.GetCompactSize() + byteCount;
+ }
}
}
diff --git a/src/Neo.Core/Extensions/TypeExtensions.cs b/src/Neo.Core/Extensions/TypeExtensions.cs
index 68775e1..61e7ed7 100644
--- a/src/Neo.Core/Extensions/TypeExtensions.cs
+++ b/src/Neo.Core/Extensions/TypeExtensions.cs
@@ -29,16 +29,14 @@ public static class TypeExtensions
{
public static object CreateInitializedObject(this Type source)
{
- // Try to get the private parameterless constructor
+ // Try to get the private/public parameterless constructor.
var ctor = source.GetConstructor(
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance,
null,
- Type.EmptyTypes, // No parameters
- null);
-
- if (ctor is null)
- // Fallback: if no parameterless ctor exists at all
- throw new InvalidOperationException($"Type {source.FullName} does not have a parameterless constructor (public or private).");
+ Type.EmptyTypes,
+ null)
+ ?? throw new InvalidOperationException(
+ $"Type {source.FullName} does not have a parameterless constructor (public or private).");
return ctor.Invoke(null);
}
diff --git a/src/Neo.Core/Factory/RandomNumberFactory.cs b/src/Neo.Core/Factory/RandomNumberFactory.cs
new file mode 100644
index 0000000..5d2b031
--- /dev/null
+++ b/src/Neo.Core/Factory/RandomNumberFactory.cs
@@ -0,0 +1,272 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using System;
+using System.Buffers.Binary;
+using System.Numerics;
+using System.Security.Cryptography;
+
+namespace Neo.Core.Factory
+{
+ public static class RandomNumberFactory
+ {
+ public static sbyte NextSByte() =>
+ NextSByte(0, sbyte.MaxValue);
+
+ public static sbyte NextSByte(sbyte maxValue)
+ {
+ if (maxValue < 0)
+ throw new ArgumentOutOfRangeException(nameof(maxValue));
+
+ return NextSByte(0, maxValue);
+ }
+
+ public static sbyte NextSByte(sbyte minValue, sbyte maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ if (minValue > maxValue)
+ throw new ArgumentOutOfRangeException(nameof(minValue));
+
+ return (sbyte)(NextUInt32((uint)(maxValue - minValue)) + minValue);
+ }
+
+ public static byte NextByte() =>
+ NextByte(0, byte.MaxValue);
+
+ public static byte NextByte(byte maxValue) =>
+ NextByte(0, maxValue);
+
+ public static byte NextByte(byte minValue, byte maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ if (minValue > maxValue)
+ throw new ArgumentOutOfRangeException(nameof(minValue));
+
+ return (byte)(NextUInt32((uint)(maxValue - minValue)) + minValue);
+ }
+
+ public static short NextInt16() =>
+ NextInt16(0, short.MaxValue);
+
+ public static short NextInt16(short maxValue)
+ {
+ if (maxValue < 0)
+ throw new ArgumentOutOfRangeException(nameof(maxValue));
+
+ return NextInt16(0, maxValue);
+ }
+
+ public static short NextInt16(short minValue, short maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ if (minValue > maxValue)
+ throw new ArgumentOutOfRangeException(nameof(minValue));
+
+ return (short)(NextUInt32((uint)(maxValue - minValue)) + minValue);
+ }
+
+ public static ushort NextUInt16() =>
+ NextUInt16(0, ushort.MaxValue);
+
+ public static ushort NextUInt16(ushort maxValue) =>
+ NextUInt16(0, maxValue);
+
+ public static ushort NextUInt16(ushort minValue, ushort maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ if (minValue > maxValue)
+ throw new ArgumentOutOfRangeException(nameof(minValue));
+
+ return (ushort)(NextUInt32((uint)(maxValue - minValue)) + minValue);
+ }
+
+ public static int NextInt32() =>
+ NextInt32(0, int.MaxValue);
+
+ public static int NextInt32(int maxValue)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(maxValue);
+
+ return NextInt32(0, maxValue);
+ }
+
+ public static int NextInt32(int minValue, int maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+
+ return (int)NextUInt32((uint)(maxValue - minValue)) + minValue;
+ }
+
+ public static uint NextUInt32() =>
+ BinaryPrimitives.ReadUInt32LittleEndian(NextBytes(4, true));
+
+ public static uint NextUInt32(uint maxValue)
+ {
+ var randomProduct = (ulong)maxValue * NextUInt32();
+ var lowPart = (uint)randomProduct;
+
+ if (lowPart < maxValue)
+ {
+ var remainder = (0u - maxValue) % maxValue;
+
+ while (lowPart < remainder)
+ {
+ randomProduct = (ulong)maxValue * NextUInt32();
+ lowPart = (uint)randomProduct;
+ }
+ }
+
+ return (uint)(randomProduct >> 32);
+ }
+
+ public static uint NextUInt32(uint minValue, uint maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+
+ return NextUInt32(maxValue - minValue) + minValue;
+ }
+
+ public static long NextInt64() =>
+ NextInt64(0L, long.MaxValue);
+
+ public static long NextInt64(long maxValue)
+ {
+ return NextInt64(0L, maxValue);
+ }
+
+ public static long NextInt64(long minValue, long maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+
+ return (long)NextUInt64((ulong)(maxValue - minValue)) + minValue;
+ }
+
+ public static ulong NextUInt64() =>
+ BinaryPrimitives.ReadUInt64LittleEndian(NextBytes(8, true));
+
+ public static ulong NextUInt64(ulong maxValue)
+ {
+ var randomProduct = Math.BigMul(maxValue, NextUInt64(), out var lowPart);
+
+ if (lowPart < maxValue)
+ {
+ var remainder = (0ul - maxValue) % maxValue;
+
+ while (lowPart < remainder)
+ {
+ randomProduct = Math.BigMul(maxValue, NextUInt64(), out lowPart);
+ }
+ }
+
+ return randomProduct;
+ }
+
+ public static ulong NextUInt64(ulong minValue, ulong maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+
+ return NextUInt64(maxValue - minValue) + minValue;
+ }
+
+ public static BigInteger NextBigInteger(BigInteger minValue, BigInteger maxValue)
+ {
+ if (minValue == maxValue) return maxValue;
+
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+
+ return NextBigInteger(maxValue - minValue) + minValue;
+ }
+
+ public static BigInteger NextBigInteger(BigInteger maxValue)
+ {
+ if (maxValue.Sign < 0)
+ throw new ArgumentOutOfRangeException(nameof(maxValue));
+
+ if (maxValue == 0 || maxValue == 1)
+ return BigInteger.Zero;
+
+ var maxValueBits = maxValue.GetByteCount() * 8;
+ var maxMaxValue = BigInteger.One << maxValueBits;
+
+ var randomProduct = maxValue * NextBigInteger(maxValueBits);
+ var lowPart = randomProduct % maxMaxValue;
+
+ if (lowPart < maxValue)
+ {
+ var threshold = (maxMaxValue - maxValue) % maxValue;
+
+ while (lowPart < threshold)
+ {
+ randomProduct = maxValue * NextBigInteger(maxValueBits);
+ lowPart = randomProduct % maxMaxValue;
+ }
+ }
+
+ return randomProduct >> maxValueBits;
+ }
+
+ public static BigInteger NextBigInteger(int sizeInBits)
+ {
+ if (sizeInBits < 0)
+ throw new ArgumentException("sizeInBits must be non-negative.");
+
+ if (sizeInBits == 0)
+ return BigInteger.Zero;
+
+ Span b = stackalloc byte[sizeInBits / 8 + 1];
+ RandomNumberGenerator.Fill(b);
+
+ if (sizeInBits % 8 == 0)
+ b[^1] = 0;
+ else
+ b[^1] &= (byte)((1 << sizeInBits % 8) - 1);
+
+ return new BigInteger(b);
+ }
+
+ public static byte[] NextBytes(int length, bool cryptography = false)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(length, 0, nameof(length));
+
+ var bytes = new byte[length];
+
+ if (cryptography)
+ RandomNumberGenerator.Fill(bytes);
+ else
+ Random.Shared.NextBytes(bytes);
+
+ return bytes;
+ }
+ }
+}
diff --git a/src/Neo.Core/Neo.Core.csproj b/src/Neo.Core/Neo.Core.csproj
index e708933..76b428c 100644
--- a/src/Neo.Core/Neo.Core.csproj
+++ b/src/Neo.Core/Neo.Core.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/Neo.Core/Net/Message/ArchivalNodeCapabilityMessage.cs b/src/Neo.Core/Net/Message/ArchivalNodeCapabilityMessage.cs
new file mode 100644
index 0000000..177c925
--- /dev/null
+++ b/src/Neo.Core/Net/Message/ArchivalNodeCapabilityMessage.cs
@@ -0,0 +1,48 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using System;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ public class ArchivalNodeCapabilityMessage : NodeCapabilityMessage
+ {
+ public override NodeCapabilityType Type => NodeCapabilityType.ArchivalNode;
+
+ public override int Size =>
+ base.Size +
+ 1;
+
+ protected override void DeserializeWithoutType(Stream reader)
+ {
+ if (reader.ReadByte() != 0)
+ throw new FormatException("Invalid capability data");
+ }
+
+ protected override void SerializeWithoutType(Stream writer)
+ {
+ writer.Write((byte)0);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/DisableCompressionCapabilityMessage.cs b/src/Neo.Core/Net/Message/DisableCompressionCapabilityMessage.cs
new file mode 100644
index 0000000..ba4dd32
--- /dev/null
+++ b/src/Neo.Core/Net/Message/DisableCompressionCapabilityMessage.cs
@@ -0,0 +1,48 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using System;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ public class DisableCompressionCapabilityMessage : NodeCapabilityMessage
+ {
+ public override NodeCapabilityType Type => NodeCapabilityType.DisableCompression;
+
+ public override int Size =>
+ base.Size +
+ 1;
+
+ protected override void DeserializeWithoutType(Stream reader)
+ {
+ if (reader.ReadByte() != 0)
+ throw new FormatException("Invalid capability data");
+ }
+
+ protected override void SerializeWithoutType(Stream writer)
+ {
+ writer.Write((byte)0);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/FullNodeCapabilityMessage.cs b/src/Neo.Core/Net/Message/FullNodeCapabilityMessage.cs
new file mode 100644
index 0000000..671cc10
--- /dev/null
+++ b/src/Neo.Core/Net/Message/FullNodeCapabilityMessage.cs
@@ -0,0 +1,57 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ public class FullNodeCapabilityMessage : NodeCapabilityMessage
+ {
+ public override NodeCapabilityType Type => NodeCapabilityType.FullNode;
+
+ public uint StartHeight { get; private set; }
+
+ public override int Size =>
+ base.Size +
+ sizeof(uint);
+
+ public FullNodeCapabilityMessage()
+ {
+ }
+
+ public FullNodeCapabilityMessage(uint startHeight)
+ {
+ StartHeight = startHeight;
+ }
+
+ protected override void DeserializeWithoutType(Stream reader)
+ {
+ StartHeight = reader.Read();
+ }
+
+ protected override void SerializeWithoutType(Stream writer)
+ {
+ writer.Write(StartHeight);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/NodeCapabilityMessage.cs b/src/Neo.Core/Net/Message/NodeCapabilityMessage.cs
new file mode 100644
index 0000000..8e8e3e7
--- /dev/null
+++ b/src/Neo.Core/Net/Message/NodeCapabilityMessage.cs
@@ -0,0 +1,92 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Serialization;
+using System;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ ///
+ /// Neo N3 node capability on the wire:
+ /// [Type:1][type-specific payload]
+ /// Type is read once in ; subclasses only read/write payload.
+ ///
+ public abstract class NodeCapabilityMessage : INeoSerializable
+ {
+ public abstract NodeCapabilityType Type { get; }
+
+ public virtual int Size =>
+ sizeof(NodeCapabilityType);
+
+ ///
+ /// Reads one capability: type byte, then subtype payload (Neo DeserializeFrom).
+ ///
+ public static NodeCapabilityMessage DeserializeFrom(Stream reader)
+ {
+ NodeCapabilityMessage capability = reader.Read() switch
+ {
+ NodeCapabilityType.TcpServer => new ServerCapabilityMessage(),
+ NodeCapabilityType.FullNode => new FullNodeCapabilityMessage(),
+ NodeCapabilityType.ArchivalNode => new ArchivalNodeCapabilityMessage(),
+ NodeCapabilityType.DisableCompression => new DisableCompressionCapabilityMessage(),
+ _ => throw new FormatException("Unknown Capability"),
+ };
+
+ // Type already consumed — payload only.
+ capability.DeserializeWithoutType(reader);
+
+ return capability;
+ }
+
+ ///
+ /// Full deserialize when the type byte is still ahead in the stream
+ /// (e.g. standalone capability as ).
+ ///
+ public void Deserialize(Stream reader)
+ {
+ var type = reader.Read();
+
+ if (type != Type)
+ throw new FormatException($"Invalid capability type: {type}");
+
+ DeserializeWithoutType(reader);
+ }
+
+ public void Serialize(Stream writer)
+ {
+ writer.Write(Type);
+ SerializeWithoutType(writer);
+ }
+
+ ///
+ /// Deserializes only the type-specific payload (type byte already consumed).
+ ///
+ protected abstract void DeserializeWithoutType(Stream reader);
+
+ ///
+ /// Serializes only the type-specific payload (type written by ).
+ ///
+ protected abstract void SerializeWithoutType(Stream writer);
+ }
+}
diff --git a/src/Neo.Core/Net/Message/PingMessage.cs b/src/Neo.Core/Net/Message/PingMessage.cs
new file mode 100644
index 0000000..eb79aa2
--- /dev/null
+++ b/src/Neo.Core/Net/Message/PingMessage.cs
@@ -0,0 +1,72 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Factory;
+using Neo.Core.Serialization;
+using System;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ ///
+ /// Payload for Ping and Pong (Neo PingPayload).
+ ///
+ public class PingMessage : INeoSerializable
+ {
+ public uint LastBlockIndex { get; private set; }
+
+ public uint Timestamp { get; private set; }
+
+ public uint Nonce { get; private set; }
+
+ public int Size =>
+ sizeof(uint) + // LastBlockIndex
+ sizeof(uint) + // Timestamp
+ sizeof(uint); // Nonce
+
+ public static PingMessage Create(uint lastBlockIndex) =>
+ Create(lastBlockIndex, RandomNumberFactory.NextUInt32());
+
+ public static PingMessage Create(uint lastBlockIndex, uint nonce) =>
+ new()
+ {
+ LastBlockIndex = lastBlockIndex,
+ Timestamp = CoreUtilities.ToUnixTimeSeconds(DateTime.UtcNow),
+ Nonce = nonce,
+ };
+
+ public void Deserialize(Stream reader)
+ {
+ LastBlockIndex = reader.Read();
+ Timestamp = reader.Read();
+ Nonce = reader.Read();
+ }
+
+ public void Serialize(Stream writer)
+ {
+ writer.Write(LastBlockIndex);
+ writer.Write(Timestamp);
+ writer.Write(Nonce);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/ProtocolMessage.cs b/src/Neo.Core/Net/Message/ProtocolMessage.cs
new file mode 100644
index 0000000..b457bd7
--- /dev/null
+++ b/src/Neo.Core/Net/Message/ProtocolMessage.cs
@@ -0,0 +1,253 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Serialization;
+using System;
+using System.Buffers.Binary;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ ///
+ /// Neo N3 P2P wire frame:
+ /// [Flags:1][Command:1][VarSize length][payload]
+ /// Matches neo-project Message framing (master-n3).
+ ///
+ public class ProtocolMessage : INeoSerializable
+ {
+ ///
+ /// Maximum allowed payload size (same as Neo N3 Message.PayloadMaxSize).
+ ///
+ public const int PayloadMaxSize = 0x0200_0000;
+
+ private const int CompressionMinSize = 128;
+ private const int CompressionThreshold = 64;
+
+ public ProtocolMessageFlags Flags { get; private set; }
+
+ public ProtocolMessageCommand Command { get; private set; }
+
+ public INeoSerializable? Message => _protocolMessage;
+
+ public bool Compressed => Flags.HasFlag(ProtocolMessageFlags.Compressed);
+
+ ///
+ /// Payload bytes written on the wire (may be LZ4-compressed).
+ ///
+ public ReadOnlyMemory PayloadBytes => _compressedPayloadBytes;
+
+ public int Size =>
+ sizeof(ProtocolMessageFlags) +
+ sizeof(ProtocolMessageCommand) +
+ _compressedPayloadBytes.GetSerializedSize();
+
+ private INeoSerializable? _protocolMessage;
+ private ReadOnlyMemory _payloadRaw;
+ private ReadOnlyMemory _compressedPayloadBytes;
+
+ ///
+ /// Creates an outbound protocol message (Neo Message.Create).
+ /// Optionally LZ4-compresses the payload for commands that allow it.
+ ///
+ public static ProtocolMessage Create(ProtocolMessageCommand command, INeoSerializable? payload = null)
+ {
+ var rawPayload = payload is null
+ ? ReadOnlyMemory.Empty
+ : payload.ToArray();
+
+ if (rawPayload.Length > PayloadMaxSize)
+ {
+ throw new FormatException(
+ $"Invalid payload length: {rawPayload.Length}. " +
+ $"The payload size exceeds the maximum allowed size of {PayloadMaxSize} bytes.");
+ }
+
+ var message = new ProtocolMessage
+ {
+ Flags = ProtocolMessageFlags.None,
+ Command = command,
+ _protocolMessage = payload,
+ _payloadRaw = rawPayload,
+ _compressedPayloadBytes = rawPayload,
+ };
+
+ if (ShallCompress(command) && rawPayload.Length > CompressionMinSize)
+ {
+ var compressed = rawPayload.Span.ToLz4Compress().ToArray();
+
+ // Only keep compression when it saves at least CompressionThreshold bytes.
+ if (compressed.Length < rawPayload.Length - CompressionThreshold)
+ {
+ message._compressedPayloadBytes = compressed;
+ message.Flags |= ProtocolMessageFlags.Compressed;
+ }
+ }
+
+ return message;
+ }
+
+ ///
+ /// Tries to read one complete protocol frame from a buffer.
+ ///
+ ///
+ /// Bytes consumed for this frame, or 0 if more data is needed.
+ ///
+ /// Payload length exceeds .
+ public static int TryRead(ReadOnlySpan data, out ProtocolMessage? message)
+ {
+ message = null;
+
+ if (data.Length < 3)
+ return 0;
+
+ var flags = (ProtocolMessageFlags)data[0];
+ var command = (ProtocolMessageCommand)data[1];
+
+ ulong payloadLength = data[2];
+ var payloadOffset = 3;
+
+ if (payloadLength == 0xfd)
+ {
+ if (data.Length < 5)
+ return 0;
+
+ payloadLength = BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(3, 2));
+ payloadOffset = 5;
+ }
+ else if (payloadLength == 0xfe)
+ {
+ if (data.Length < 7)
+ return 0;
+
+ payloadLength = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(3, 4));
+ payloadOffset = 7;
+ }
+ else if (payloadLength == 0xff)
+ {
+ if (data.Length < 11)
+ return 0;
+
+ payloadLength = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(3, 8));
+ payloadOffset = 11;
+ }
+
+ if (payloadLength > PayloadMaxSize)
+ {
+ throw new FormatException(
+ $"Invalid payload length: {payloadLength}. " +
+ $"The payload size exceeds the maximum allowed size of {PayloadMaxSize} bytes.");
+ }
+
+ if ((ulong)data.Length < (ulong)payloadOffset + payloadLength)
+ return 0;
+
+ var payload = payloadLength == 0
+ ? ReadOnlyMemory.Empty
+ : data.Slice(payloadOffset, (int)payloadLength).ToArray();
+
+ message = new ProtocolMessage
+ {
+ Flags = flags,
+ Command = command,
+ _compressedPayloadBytes = payload,
+ _payloadRaw = payload,
+ };
+
+ message.DecodePayload();
+
+ return payloadOffset + (int)payloadLength;
+ }
+
+ ///
+ /// Serializes this frame to bytes.
+ /// When is false, the compressed flag is cleared
+ /// and the uncompressed payload is sent (Neo: until remote Version allows compression).
+ ///
+ public byte[] ToArray(bool allowCompression = true)
+ {
+ if (allowCompression || Compressed == false)
+ return ((INeoSerializable)this).ToArray();
+
+ using var ms = new MemoryStream();
+ ms.Write(Flags & ~ProtocolMessageFlags.Compressed);
+ ms.Write(Command);
+ ms.Write(_payloadRaw.Span);
+ return ms.ToArray();
+ }
+
+ public void Deserialize(Stream reader)
+ {
+ Flags = reader.Read();
+ Command = reader.Read();
+ _compressedPayloadBytes = reader.ReadDynamic();
+ _payloadRaw = _compressedPayloadBytes;
+
+ DecodePayload();
+ }
+
+ public void Serialize(Stream writer)
+ {
+ writer.Write(Flags);
+ writer.Write(Command);
+ writer.Write(_compressedPayloadBytes.Span);
+ }
+
+ private static bool ShallCompress(ProtocolMessageCommand command) =>
+ command is ProtocolMessageCommand.Block
+ or ProtocolMessageCommand.Extensible
+ or ProtocolMessageCommand.Transaction
+ or ProtocolMessageCommand.Headers
+ or ProtocolMessageCommand.Address
+ or ProtocolMessageCommand.MerkleBlock
+ or ProtocolMessageCommand.FilterLoad
+ or ProtocolMessageCommand.FilterAdd;
+
+ private void DecodePayload()
+ {
+ if (_compressedPayloadBytes.Length == 0)
+ {
+ _protocolMessage = null;
+ _payloadRaw = ReadOnlyMemory.Empty;
+ return;
+ }
+
+ var decompressedBytes = Flags.HasFlag(ProtocolMessageFlags.Compressed)
+ ? _compressedPayloadBytes.Span.ToLz4Decompress()
+ : _compressedPayloadBytes.Span;
+
+ // After decompress, raw payload is the logical body (for re-send without compression).
+ if (Flags.HasFlag(ProtocolMessageFlags.Compressed))
+ _payloadRaw = decompressedBytes.ToArray();
+ else
+ _payloadRaw = _compressedPayloadBytes;
+
+ _protocolMessage = Command switch
+ {
+ ProtocolMessageCommand.Version => decompressedBytes.AsSerializable(),
+ ProtocolMessageCommand.Ping or ProtocolMessageCommand.Pong =>
+ decompressedBytes.AsSerializable(),
+ _ => null,
+ };
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/ServerCapabilityMessage.cs b/src/Neo.Core/Net/Message/ServerCapabilityMessage.cs
new file mode 100644
index 0000000..0b0c202
--- /dev/null
+++ b/src/Neo.Core/Net/Message/ServerCapabilityMessage.cs
@@ -0,0 +1,57 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using System.IO;
+
+namespace Neo.Core.Net.Message
+{
+ public class ServerCapabilityMessage : NodeCapabilityMessage
+ {
+ public override NodeCapabilityType Type => NodeCapabilityType.TcpServer;
+
+ public ushort Port { get; private set; }
+
+ public override int Size =>
+ base.Size +
+ sizeof(ushort);
+
+ public ServerCapabilityMessage()
+ {
+ }
+
+ public ServerCapabilityMessage(ushort port)
+ {
+ Port = port;
+ }
+
+ protected override void DeserializeWithoutType(Stream reader)
+ {
+ Port = reader.Read();
+ }
+
+ protected override void SerializeWithoutType(Stream writer)
+ {
+ writer.Write(Port);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/Message/VersionMessage.cs b/src/Neo.Core/Net/Message/VersionMessage.cs
new file mode 100644
index 0000000..a127805
--- /dev/null
+++ b/src/Neo.Core/Net/Message/VersionMessage.cs
@@ -0,0 +1,107 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Factory;
+using Neo.Core.Serialization;
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+
+namespace Neo.Core.Net.Message
+{
+ public class VersionMessage : INeoSerializable
+ {
+ public const int MaxCapabilities = 32;
+
+ private readonly static string s_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
+
+ public uint Network { get; private set; }
+
+ public uint Version { get; private set; } = 0u;
+
+ public uint Timestamp { get; private set; } = CoreUtilities.ToUnixTimeSeconds(DateTime.UtcNow);
+
+ public uint Nonce { get; private set; } = RandomNumberFactory.NextUInt32();
+
+ public bool AllowCompression => !Capabilities.Any(static a => a.Type == NodeCapabilityType.DisableCompression);
+
+ public string UserAgent { get; private set; } = $"/RapidLoop:{s_version}/";
+
+ public NodeCapabilityMessage[] Capabilities { get; private set; } = [];
+
+ public int Size =>
+ sizeof(uint) + // Network
+ sizeof(uint) + // Version
+ sizeof(uint) + // Timestamp
+ sizeof(uint) + // Nonce
+ UserAgent.GetSerializedSize() + // UserAgent
+ Capabilities.GetSerializedSize(); // Node Capabilities
+
+ public static VersionMessage Create(uint network, params NodeCapabilityMessage[] capabilities) =>
+ new()
+ {
+ Network = network,
+ Capabilities = capabilities,
+ };
+
+ public static VersionMessage Create(uint network, uint nonce, params NodeCapabilityMessage[] capabilities) =>
+ new()
+ {
+ Network = network,
+ Nonce = nonce,
+ Capabilities = capabilities,
+ };
+
+ public void Deserialize(Stream reader)
+ {
+ Network = reader.Read();
+ Version = reader.Read();
+ Timestamp = reader.Read();
+ Nonce = reader.Read();
+ UserAgent = reader.ReadString();
+
+ var capabilityCount = reader.ReadCompact();
+ if (capabilityCount > MaxCapabilities)
+ throw new FormatException($"Invalid capabilities count: {capabilityCount}.");
+
+ Capabilities = new NodeCapabilityMessage[capabilityCount];
+ for (var i = 0; i < Capabilities.Length; i++)
+ Capabilities[i] = NodeCapabilityMessage.DeserializeFrom(reader);
+
+ // Duplicate check is by capability Type (Neo VersionPayload).
+ if (Capabilities.Select(static c => c.Type).Distinct().Count() != Capabilities.Length)
+ throw new FormatException("Duplicate node capabilities.");
+ }
+
+ public void Serialize(Stream writer)
+ {
+ writer.Write(Network);
+ writer.Write(Version);
+ writer.Write(Timestamp);
+ writer.Write(Nonce);
+ writer.Write(UserAgent);
+ writer.WriteObjects(Capabilities);
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/NodeCapabilityType.cs b/src/Neo.Core/Net/NodeCapabilityType.cs
new file mode 100644
index 0000000..47cafeb
--- /dev/null
+++ b/src/Neo.Core/Net/NodeCapabilityType.cs
@@ -0,0 +1,67 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+namespace Neo.Core.Net
+{
+ public enum NodeCapabilityType : byte
+ {
+ #region Servers
+
+ ///
+ /// Indicates that the node is listening on a Tcp port.
+ ///
+ TcpServer = 0x01,
+
+ ///
+ /// Disable p2p compression
+ ///
+ DisableCompression = 0x03,
+
+ #endregion
+
+ #region Data availability
+
+ ///
+ /// Indicates that the node has complete current state.
+ ///
+ FullNode = 0x10,
+
+ ///
+ /// Indicates that the node stores full block history. These nodes can be used
+ /// for P2P synchronization from genesis (other ones can cut the tail and
+ /// won't respond to requests for old (wrt MaxTraceableBlocks) blocks).
+ ///
+ ArchivalNode = 0x11,
+
+ #endregion
+
+ #region Private extensions
+
+ ///
+ /// The first extension ID. Any subsequent can be used in an
+ /// implementation-specific way.
+ ///
+ Extension0 = 0xf0
+
+ #endregion
+ }
+}
diff --git a/src/Neo.Core/Net/NodeConnection.cs b/src/Neo.Core/Net/NodeConnection.cs
new file mode 100644
index 0000000..90808e6
--- /dev/null
+++ b/src/Neo.Core/Net/NodeConnection.cs
@@ -0,0 +1,645 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Neo.Core.Net.Message;
+using System;
+using System.Buffers;
+using System.IO;
+using System.IO.Pipelines;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Neo.Core.Net
+{
+ ///
+ /// TCP connection that frames Neo P2P messages, runs Version/Verack handshake,
+ /// and maintains the link with Ping/Pong + idle timeouts.
+ ///
+ public class NodeConnection : IAsyncDisposable
+ {
+ ///
+ /// Initial timeout before any complete frame is received (Neo connection start limit).
+ ///
+ public static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Idle timeout after the last received frame (Neo connection limit).
+ ///
+ public static readonly TimeSpan ReceiveIdleTimeout = TimeSpan.FromSeconds(60);
+
+ ///
+ /// How often keep-alive checks run.
+ ///
+ public static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Send a Ping if nothing has been sent for this long (Neo RemoteNode timer).
+ ///
+ public static readonly TimeSpan SendIdleBeforePing = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Raised for each complete frame after the handshake is Ready
+ /// (including Ping/Pong after internal handling).
+ ///
+ public event EventHandler? MessageReceived;
+
+ ///
+ /// Raised once when Version + Verack exchange completes successfully.
+ ///
+ public event EventHandler? HandshakeCompleted;
+
+ ///
+ /// Raised once when the connection is closed (read loop ended or dispose).
+ ///
+ public event EventHandler? Disconnected;
+
+ public bool Connected => _socket.Connected;
+
+ public EndPoint? RemoteEndPoint => _socket.RemoteEndPoint;
+
+ public EndPoint? LocalEndPoint => _socket.LocalEndPoint;
+
+ public NodeHandshakeState HandshakeState => _handshake.State;
+
+ public bool IsReady => _handshake.IsReady;
+
+ public VersionMessage? RemoteVersion => _handshake.RemoteVersion;
+
+ ///
+ /// Highest block index reported by the remote peer (Version FullNode / Ping / Pong).
+ ///
+ public uint RemoteLastBlockIndex => (uint)Volatile.Read(ref _remoteLastBlockIndex);
+
+ ///
+ /// Local chain height included in outbound Ping/Pong. Host code should update this.
+ ///
+ public uint LocalBlockIndex
+ {
+ get => (uint)Volatile.Read(ref _localBlockIndex);
+ set => Volatile.Write(ref _localBlockIndex, value);
+ }
+
+ private readonly Socket _socket;
+ private readonly Pipe _networkPipe;
+ private readonly ILogger _logger;
+ private readonly ProtocolSettings _protocolSettings;
+ private readonly uint _localNonce;
+ private readonly NodeCapabilityMessage[] _localCapabilities;
+ private readonly NodeHandshake _handshake;
+ private readonly SemaphoreSlim _sendLock = new(1, 1);
+ private readonly CancellationTokenSource _lifetimeCts = new();
+ private readonly long _startedTicks = DateTime.UtcNow.Ticks;
+
+ private Task _fillingPipeTask = Task.CompletedTask;
+ private Task _protocolTask = Task.CompletedTask;
+ private Task _keepAliveTask = Task.CompletedTask;
+ private int _disconnected;
+ private long _lastReceivedTicks;
+ private long _lastSentTicks;
+ private long _localBlockIndex;
+ private long _remoteLastBlockIndex;
+
+ public NodeConnection(
+ Socket socket,
+ ProtocolSettings protocolSettings,
+ uint localNonce,
+ NodeCapabilityMessage[]? localCapabilities = default,
+ ILoggerFactory? loggerFactory = default)
+ {
+ ArgumentNullException.ThrowIfNull(socket);
+ ArgumentNullException.ThrowIfNull(protocolSettings);
+
+ _socket = socket;
+ _protocolSettings = protocolSettings;
+ _localNonce = localNonce;
+ _localCapabilities = localCapabilities ?? [];
+ _networkPipe = new Pipe();
+ _handshake = new NodeHandshake(protocolSettings.Network, localNonce);
+ _logger = (loggerFactory ?? NullLoggerFactory.Instance)
+ .CreateLogger();
+
+ var now = DateTime.UtcNow.Ticks;
+ _lastReceivedTicks = now;
+ _lastSentTicks = now;
+ }
+
+ ///
+ /// Dials , starts framing + handshake (sends Version).
+ /// Caller owns the returned connection and should dispose it.
+ ///
+ public static async Task ConnectAsync(
+ IPEndPoint remoteEndPoint,
+ ProtocolSettings protocolSettings,
+ uint localNonce,
+ NodeCapabilityMessage[]? localCapabilities = null,
+ ILoggerFactory? loggerFactory = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(remoteEndPoint);
+ ArgumentNullException.ThrowIfNull(protocolSettings);
+
+ var socket = new Socket(remoteEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
+
+ try
+ {
+ await socket.ConnectAsync(remoteEndPoint, cancellationToken).ConfigureAwait(false);
+
+ var connection = new NodeConnection(
+ socket,
+ protocolSettings,
+ localNonce,
+ localCapabilities,
+ loggerFactory);
+
+ connection.Start();
+ return connection;
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Completes when the Version/Verack handshake finishes, or fails if the connection drops first.
+ ///
+ public async Task WaitForHandshakeAsync(CancellationToken cancellationToken = default)
+ {
+ if (IsReady)
+ return;
+
+ if (Volatile.Read(ref _disconnected) != 0)
+ throw new IOException("Connection closed before handshake completed.");
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnCompleted(object? sender, EventArgs e) =>
+ tcs.TrySetResult();
+
+ void OnDisconnected(object? sender, EventArgs e) =>
+ tcs.TrySetException(new IOException("Connection closed before handshake completed."));
+
+ HandshakeCompleted += OnCompleted;
+ Disconnected += OnDisconnected;
+
+ try
+ {
+ // Re-check after subscribe to close the race with concurrent handshake/disconnect.
+ if (IsReady)
+ return;
+
+ if (Volatile.Read(ref _disconnected) != 0)
+ throw new IOException("Connection closed before handshake completed.");
+
+ await using (cancellationToken.Register(static state =>
+ {
+ var source = (TaskCompletionSource)state!;
+ source.TrySetCanceled();
+ }, tcs))
+ {
+ await tcs.Task.ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ HandshakeCompleted -= OnCompleted;
+ Disconnected -= OnDisconnected;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ _lifetimeCts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ _socket.Dispose();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ await _protocolTask.ConfigureAwait(false);
+ await _keepAliveTask.ConfigureAwait(false);
+ await _fillingPipeTask.ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ }
+
+ _sendLock.Dispose();
+ _lifetimeCts.Dispose();
+ NotifyDisconnected();
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Starts receive pumps, keep-alive, and sends the local Version (Neo StartProtocol).
+ ///
+ ///
+ /// Local Version is sent before the frame reader runs. Otherwise a Verack reply to the
+ /// peer's Version can race ahead of our Version on the wire; the peer then sees Verack
+ /// first and aborts with a protocol violation (common on macOS CI scheduling).
+ /// Socket fill still runs concurrently so peer bytes buffer in the pipe until we read.
+ ///
+ internal void Start()
+ {
+ _fillingPipeTask = DoFillPipeAsync();
+ _protocolTask = RunProtocolAsync();
+ _keepAliveTask = RunKeepAliveAsync(_lifetimeCts.Token);
+ }
+
+ ///
+ /// Send local Version, then process inbound frames (including remote Version / Verack).
+ ///
+ private async Task RunProtocolAsync()
+ {
+ await StartProtocolAsync().ConfigureAwait(false);
+ await DoReadPipeAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends one framed protocol message.
+ ///
+ public async ValueTask SendAsync(
+ ProtocolMessage message,
+ bool? allowCompression = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ var compress = allowCompression ?? _handshake.RemoteAllowsCompression;
+ var bytes = message.ToArray(compress);
+
+ await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _socket.SendAsync(bytes, SocketFlags.None, cancellationToken).ConfigureAwait(false);
+ TouchSent();
+ }
+ finally
+ {
+ _sendLock.Release();
+ }
+ }
+
+ private async Task StartProtocolAsync()
+ {
+ try
+ {
+ var version = VersionMessage.Create(
+ _protocolSettings.Network,
+ _localNonce,
+ _localCapabilities);
+
+ await SendAsync(
+ ProtocolMessage.Create(ProtocolMessageCommand.Version, version),
+ allowCompression: false).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to start protocol (send Version).");
+ AbortSocket();
+ }
+ }
+
+ private async Task RunKeepAliveAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var timer = new PeriodicTimer(KeepAliveInterval);
+
+ while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
+ {
+ var now = DateTime.UtcNow;
+ var lastReceived = new DateTime(Interlocked.Read(ref _lastReceivedTicks), DateTimeKind.Utc);
+ var lastSent = new DateTime(Interlocked.Read(ref _lastSentTicks), DateTimeKind.Utc);
+ var started = new DateTime(_startedTicks, DateTimeKind.Utc);
+
+ if (IsReady == false)
+ {
+ // Neo: short timeout before the connection is fully active.
+ if (now - started >= HandshakeTimeout)
+ {
+ _logger.LogWarning(
+ "Handshake timeout with {Remote} after {Timeout}.",
+ RemoteEndPoint,
+ HandshakeTimeout);
+ AbortSocket();
+ break;
+ }
+
+ continue;
+ }
+
+ if (now - lastReceived >= ReceiveIdleTimeout)
+ {
+ _logger.LogWarning(
+ "Receive idle timeout with {Remote} after {Timeout}.",
+ RemoteEndPoint,
+ ReceiveIdleTimeout);
+ AbortSocket();
+ break;
+ }
+
+ if (now - lastSent >= SendIdleBeforePing)
+ {
+ var ping = PingMessage.Create(LocalBlockIndex);
+ await SendAsync(
+ ProtocolMessage.Create(ProtocolMessageCommand.Ping, ping),
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Keep-alive loop ended.");
+ }
+ }
+
+ private async Task DoFillPipeAsync()
+ {
+ Exception? error = null;
+ var writer = _networkPipe.Writer;
+
+ try
+ {
+ while (true)
+ {
+ var memory = writer.GetMemory(_socket.ReceiveBufferSize);
+ var bytesReceived = await _socket.ReceiveAsync(memory, SocketFlags.None).ConfigureAwait(false);
+
+ if (bytesReceived == 0)
+ break;
+
+ writer.Advance(bytesReceived);
+
+ var result = await writer.FlushAsync().ConfigureAwait(false);
+ if (result.IsCompleted || result.IsCanceled)
+ break;
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (SocketException ex)
+ {
+ error = ex;
+ _logger.LogDebug(ex, "Socket receive ended.");
+ }
+ catch (Exception ex)
+ {
+ error = ex;
+ _logger.LogError(ex, "Failed to fill network pipe.");
+ }
+ finally
+ {
+ await writer.CompleteAsync(error).ConfigureAwait(false);
+ }
+ }
+
+ private async Task DoReadPipeAsync()
+ {
+ Exception? unexpectedError = null;
+ var reader = _networkPipe.Reader;
+
+ try
+ {
+ while (true)
+ {
+ var readResult = await reader.ReadAsync().ConfigureAwait(false);
+
+ if (readResult.IsCanceled)
+ break;
+
+ var buffer = readResult.Buffer;
+
+ while (TryReadFrame(ref buffer, out var message))
+ await HandleMessageAsync(message!).ConfigureAwait(false);
+
+ reader.AdvanceTo(buffer.Start, buffer.End);
+
+ if (readResult.IsCompleted)
+ {
+ if (buffer.Length > 0)
+ throw new FormatException("Connection closed with an incomplete protocol frame.");
+
+ break;
+ }
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ catch (Exception ex)
+ {
+ unexpectedError = ex;
+ _logger.LogError(ex, "Failed to read protocol frames.");
+ }
+ finally
+ {
+ await reader.CompleteAsync(unexpectedError).ConfigureAwait(false);
+
+ try
+ {
+ _networkPipe.Writer.CancelPendingFlush();
+ }
+ catch (InvalidOperationException)
+ {
+ }
+
+ AbortSocket();
+ NotifyDisconnected();
+ }
+ }
+
+ private void NotifyDisconnected()
+ {
+ if (Interlocked.Exchange(ref _disconnected, 1) != 0)
+ return;
+
+ try
+ {
+ _lifetimeCts.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ Disconnected?.Invoke(this, EventArgs.Empty);
+ }
+
+ private async Task HandleMessageAsync(ProtocolMessage message)
+ {
+ try
+ {
+ TouchReceived();
+
+ if (_handshake.IsReady == false)
+ {
+ var reply = _handshake.Process(message);
+
+ if (reply is not null)
+ {
+ await SendAsync(reply, allowCompression: false).ConfigureAwait(false);
+ }
+
+ if (_handshake.IsReady)
+ {
+ ApplyRemoteVersionHeight(_handshake.RemoteVersion);
+ HandshakeCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ return;
+ }
+
+ // Already Ready: Process validates no second handshake messages.
+ _handshake.Process(message);
+
+ if (message.Command == ProtocolMessageCommand.Ping)
+ await HandlePingAsync(message).ConfigureAwait(false);
+ else if (message.Command == ProtocolMessageCommand.Pong)
+ HandlePong(message);
+
+ MessageReceived?.Invoke(this, message);
+ }
+ catch (ProtocolViolationException ex)
+ {
+ _logger.LogWarning(ex, "Protocol violation from {Remote}.", RemoteEndPoint);
+ AbortSocket();
+ }
+ }
+
+ private async Task HandlePingAsync(ProtocolMessage message)
+ {
+ if (message.Message is not PingMessage ping)
+ {
+ _logger.LogWarning("Ping without payload from {Remote}.", RemoteEndPoint);
+ return;
+ }
+
+ UpdateRemoteLastBlockIndex(ping.LastBlockIndex);
+
+ // Neo: Pong reuses the Ping nonce; height is local tip.
+ var pong = PingMessage.Create(LocalBlockIndex, ping.Nonce);
+ await SendAsync(ProtocolMessage.Create(ProtocolMessageCommand.Pong, pong)).ConfigureAwait(false);
+ }
+
+ private void HandlePong(ProtocolMessage message)
+ {
+ if (message.Message is not PingMessage pong)
+ return;
+
+ UpdateRemoteLastBlockIndex(pong.LastBlockIndex);
+ }
+
+ private void ApplyRemoteVersionHeight(VersionMessage? version)
+ {
+ if (version is null)
+ return;
+
+ foreach (var capability in version.Capabilities.OfType())
+ UpdateRemoteLastBlockIndex(capability.StartHeight);
+ }
+
+ private void UpdateRemoteLastBlockIndex(uint lastBlockIndex)
+ {
+ while (true)
+ {
+ var current = (uint)Volatile.Read(ref _remoteLastBlockIndex);
+ if (lastBlockIndex <= current)
+ return;
+
+ if (Interlocked.CompareExchange(ref _remoteLastBlockIndex, lastBlockIndex, current) == current)
+ return;
+ }
+ }
+
+ private void TouchReceived() =>
+ Interlocked.Exchange(ref _lastReceivedTicks, DateTime.UtcNow.Ticks);
+
+ private void TouchSent() =>
+ Interlocked.Exchange(ref _lastSentTicks, DateTime.UtcNow.Ticks);
+
+ private void AbortSocket()
+ {
+ try
+ {
+ _socket.Dispose();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+
+ ///
+ /// Tries to consume one complete frame from .
+ ///
+ internal static bool TryReadFrame(ref ReadOnlySequence buffer, out ProtocolMessage? message)
+ {
+ message = null;
+
+ if (buffer.IsEmpty)
+ return false;
+
+ int consumed;
+ if (buffer.IsSingleSegment)
+ {
+ consumed = ProtocolMessage.TryRead(buffer.FirstSpan, out message);
+ }
+ else
+ {
+ var copy = buffer.ToArray();
+ consumed = ProtocolMessage.TryRead(copy, out message);
+ }
+
+ if (consumed == 0)
+ {
+ message = null;
+ return false;
+ }
+
+ buffer = buffer.Slice(consumed);
+ return true;
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/NodeHandshake.cs b/src/Neo.Core/Net/NodeHandshake.cs
new file mode 100644
index 0000000..b1ef853
--- /dev/null
+++ b/src/Neo.Core/Net/NodeHandshake.cs
@@ -0,0 +1,112 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Net.Message;
+using System;
+using System.Net;
+
+namespace Neo.Core.Net
+{
+ ///
+ /// Pure Neo N3 handshake state machine (no I/O).
+ /// Local already sent Version; then: receive Version → send Verack → receive Verack → Ready.
+ ///
+ public sealed class NodeHandshake(uint network, uint localNonce)
+ {
+ private readonly uint _network = network;
+ private readonly uint _localNonce = localNonce;
+
+ public NodeHandshakeState State { get; private set; } = NodeHandshakeState.WaitingForVersion;
+
+ public VersionMessage? RemoteVersion { get; private set; }
+
+ public bool IsReady => State == NodeHandshakeState.Ready;
+
+ ///
+ /// Whether the remote peer allows payload compression (known after Version).
+ ///
+ public bool RemoteAllowsCompression => RemoteVersion?.AllowCompression ?? false;
+
+ ///
+ /// Processes one inbound frame.
+ ///
+ ///
+ /// An optional reply to send immediately (e.g. Verack after Version).
+ /// When and the message is a normal protocol command, returns null
+ /// and the caller should dispatch the message to application handlers.
+ ///
+ /// Wrong command order or invalid handshake data.
+ public ProtocolMessage? Process(ProtocolMessage message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+
+ return State switch
+ {
+ NodeHandshakeState.WaitingForVersion => ProcessVersion(message),
+ NodeHandshakeState.WaitingForVerack => ProcessVerack(message),
+ NodeHandshakeState.Ready => ProcessReady(message),
+ _ => throw new InvalidOperationException($"Unknown handshake state: {State}."),
+ };
+ }
+
+ private ProtocolMessage ProcessVersion(ProtocolMessage message)
+ {
+ if (message.Command != ProtocolMessageCommand.Version)
+ throw new ProtocolViolationException("Expected Version message.");
+
+ if (message.Message is not VersionMessage version)
+ throw new ProtocolViolationException("Version payload is missing or invalid.");
+
+ if (version.Network != _network)
+ {
+ throw new ProtocolViolationException(
+ $"Network mismatch: remote={version.Network}, local={_network}.");
+ }
+
+ if (version.Nonce == _localNonce)
+ throw new ProtocolViolationException("Connection to self is not allowed.");
+
+ RemoteVersion = version;
+ State = NodeHandshakeState.WaitingForVerack;
+
+ return ProtocolMessage.Create(ProtocolMessageCommand.VersionAck);
+ }
+
+ private ProtocolMessage? ProcessVerack(ProtocolMessage message)
+ {
+ if (message.Command != ProtocolMessageCommand.VersionAck)
+ throw new ProtocolViolationException("Expected Verack message.");
+
+ State = NodeHandshakeState.Ready;
+ return null;
+ }
+
+ private static ProtocolMessage? ProcessReady(ProtocolMessage message)
+ {
+ // Neo rejects Version/Verack after handshake completes.
+ if (message.Command is ProtocolMessageCommand.Version or ProtocolMessageCommand.VersionAck)
+ throw new ProtocolViolationException("Unexpected handshake message after handshake completed.");
+
+ return null;
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/NodeHandshakeState.cs b/src/Neo.Core/Net/NodeHandshakeState.cs
new file mode 100644
index 0000000..ddcdc5e
--- /dev/null
+++ b/src/Neo.Core/Net/NodeHandshakeState.cs
@@ -0,0 +1,45 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+namespace Neo.Core.Net
+{
+ ///
+ /// Neo N3 P2P handshake progress (Version → Verack → Ready).
+ ///
+ public enum NodeHandshakeState : byte
+ {
+ ///
+ /// Waiting for the remote .
+ ///
+ WaitingForVersion = 0,
+
+ ///
+ /// Local Version already processed; waiting for .
+ ///
+ WaitingForVerack = 1,
+
+ ///
+ /// Handshake finished; normal protocol messages are allowed.
+ ///
+ Ready = 2,
+ }
+}
diff --git a/src/Neo.Core/Net/NodeServerListener.cs b/src/Neo.Core/Net/NodeServerListener.cs
new file mode 100644
index 0000000..f1eec4b
--- /dev/null
+++ b/src/Neo.Core/Net/NodeServerListener.cs
@@ -0,0 +1,285 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Neo.Core.Factory;
+using Neo.Core.Net.Message;
+using System;
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Neo.Core.Net
+{
+ ///
+ /// TCP listener that accepts Neo P2P peers and starts each connection's handshake.
+ /// Bind exclusivity is enforced by the OS (no process-wide named mutex).
+ ///
+ public class NodeServerListener : IAsyncDisposable
+ {
+ ///
+ /// Default accept backlog when is called without a value.
+ ///
+ public const int DefaultBacklog = 128;
+
+ public NodeConnection[] Clients => [.. _connections.Keys];
+
+ public ProtocolSettings ProtocolSettings => _protocolSettings;
+
+ ///
+ /// Configured listen endpoint (port may be 0 before ).
+ /// Prefer after start when port 0 was used.
+ ///
+ public EndPoint LocalEndPoint => _serverSocket.LocalEndPoint ?? _endPoint;
+
+ ///
+ /// Actual bound endpoint after (resolves port 0 to the OS-assigned port).
+ ///
+ public IPEndPoint BoundEndPoint =>
+ _serverSocket.LocalEndPoint as IPEndPoint
+ ?? throw new InvalidOperationException($"Listener is not bound. Call {nameof(Start)} first.");
+
+ public bool IsActive => _isActive != 0;
+
+ ///
+ /// Random nonce identifying this node (Neo LocalNode.Nonce).
+ ///
+ public uint Nonce => _nonce;
+
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly ILogger _logger;
+
+ private readonly IPEndPoint _endPoint;
+ private readonly Socket _serverSocket;
+ private readonly ProtocolSettings _protocolSettings;
+ private readonly uint _nonce;
+ private readonly NodeCapabilityMessage[] _capabilities;
+
+ private readonly CancellationTokenSource _listeningTokenSource = new();
+ private readonly ConcurrentDictionary _connections = [];
+
+ private Task _listeningTask = Task.CompletedTask;
+ private int _isActive;
+ private int _disposed;
+
+ public NodeServerListener(
+ IPEndPoint endPoint,
+ ProtocolSettings? protocolSettings = default,
+ ILoggerFactory? loggerFactory = default)
+ {
+ ArgumentNullException.ThrowIfNull(endPoint);
+
+ _endPoint = endPoint;
+ _serverSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
+ _protocolSettings = protocolSettings ?? ProtocolSettings.Default;
+ _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
+ _logger = _loggerFactory.CreateLogger();
+ _nonce = RandomNumberFactory.NextUInt32();
+ _capabilities = CreateLocalCapabilities(endPoint);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ return;
+
+ Interlocked.Exchange(ref _isActive, 0);
+
+ try
+ {
+ _listeningTokenSource.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ // Unblock AcceptAsync.
+ try
+ {
+ _serverSocket.Dispose();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ await _listeningTask.ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ }
+
+ foreach (var client in _connections.Keys)
+ {
+ client.Disconnected -= OnClientDisconnected;
+ await client.DisposeAsync().ConfigureAwait(false);
+ }
+
+ _connections.Clear();
+ _listeningTokenSource.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public void Start()
+ {
+ Start(DefaultBacklog);
+ }
+
+ public void Start(int backlog)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(backlog);
+ ObjectDisposedException.ThrowIf(_disposed != 0, this);
+
+ if (Interlocked.CompareExchange(ref _isActive, 1, 0) != 0)
+ return;
+
+ try
+ {
+ // Exclusive bind on Windows: avoids port-sharing races under SO_REUSEADDR on CI.
+ if (OperatingSystem.IsWindows())
+ _serverSocket.ExclusiveAddressUse = true;
+
+ _serverSocket.Bind(_endPoint);
+ _serverSocket.Listen(backlog);
+ _listeningTask = AcceptLoopAsync();
+ }
+ catch
+ {
+ Interlocked.Exchange(ref _isActive, 0);
+ throw;
+ }
+ }
+
+ ///
+ /// Outbound connect to a remote peer using this node's network identity (nonce + capabilities).
+ /// The connection is tracked in until it disconnects.
+ /// Listening is optional — you may call this without starting the accept loop.
+ ///
+ public async Task ConnectAsync(
+ IPEndPoint remoteEndPoint,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(remoteEndPoint);
+ ObjectDisposedException.ThrowIf(_disposed != 0, this);
+
+ var connection = await NodeConnection.ConnectAsync(
+ remoteEndPoint,
+ _protocolSettings,
+ _nonce,
+ _capabilities,
+ _loggerFactory,
+ cancellationToken).ConfigureAwait(false);
+
+ if (TrackConnection(connection) == false)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ throw new InvalidOperationException("Failed to track outbound connection.");
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ _logger.LogDebug("Connected outbound to {Remote}.", connection.RemoteEndPoint);
+
+ return connection;
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ var token = _listeningTokenSource.Token;
+
+ while (token.IsCancellationRequested == false)
+ {
+ Socket clientSocket;
+ try
+ {
+ clientSocket = await _serverSocket.AcceptAsync(token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (ObjectDisposedException) when (_disposed != 0)
+ {
+ break;
+ }
+ catch (SocketException ex) when (token.IsCancellationRequested == false)
+ {
+ _logger.LogError(ex, "Accept failed.");
+ continue;
+ }
+
+ var clientConnection = new NodeConnection(
+ clientSocket,
+ _protocolSettings,
+ _nonce,
+ _capabilities,
+ _loggerFactory);
+
+ if (TrackConnection(clientConnection) == false)
+ {
+ await clientConnection.DisposeAsync().ConfigureAwait(false);
+ continue;
+ }
+
+ clientConnection.Start();
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ _logger.LogDebug("Accepted peer {Remote}.", clientConnection.RemoteEndPoint);
+ }
+ }
+
+ private bool TrackConnection(NodeConnection connection)
+ {
+ connection.Disconnected += OnClientDisconnected;
+
+ if (_connections.TryAdd(connection, 0))
+ return true;
+
+ connection.Disconnected -= OnClientDisconnected;
+ return false;
+ }
+
+ private void OnClientDisconnected(object? sender, EventArgs e)
+ {
+ if (sender is not NodeConnection connection)
+ return;
+
+ connection.Disconnected -= OnClientDisconnected;
+ _connections.TryRemove(connection, out _);
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ _logger.LogDebug("Peer disconnected {Remote}.", connection.RemoteEndPoint);
+ }
+
+ private static NodeCapabilityMessage[] CreateLocalCapabilities(IPEndPoint endPoint)
+ {
+ return
+ [
+ new FullNodeCapabilityMessage(startHeight: 0),
+ new ServerCapabilityMessage(port: (ushort)endPoint.Port),
+ ];
+ }
+ }
+}
diff --git a/src/Neo.Core/Net/MessageCommandType.cs b/src/Neo.Core/Net/ProtocolMessageCommand.cs
similarity index 98%
rename from src/Neo.Core/Net/MessageCommandType.cs
rename to src/Neo.Core/Net/ProtocolMessageCommand.cs
index 251a3f3..37230d5 100644
--- a/src/Neo.Core/Net/MessageCommandType.cs
+++ b/src/Neo.Core/Net/ProtocolMessageCommand.cs
@@ -25,7 +25,7 @@ namespace Neo.Core.Net
///
/// Represents the command of a message.
///
- public enum MessageCommandType : byte
+ public enum ProtocolMessageCommand : byte
{
#region handshaking
diff --git a/src/Neo.Core/Net/ProtocolMessageFlags.cs b/src/Neo.Core/Net/ProtocolMessageFlags.cs
new file mode 100644
index 0000000..36af10f
--- /dev/null
+++ b/src/Neo.Core/Net/ProtocolMessageFlags.cs
@@ -0,0 +1,40 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using System;
+
+namespace Neo.Core.Net
+{
+ [Flags]
+ public enum ProtocolMessageFlags : byte
+ {
+ ///
+ /// No flag is set for the message.
+ ///
+ None = 0,
+
+ ///
+ /// Indicates that the message is compressed.
+ ///
+ Compressed = 1 << 0
+ }
+}
diff --git a/src/Neo.IO/Hashing/Murmur32.cs b/src/Neo.IO/Hashing/Murmur32.cs
index b18315a..16ecca2 100644
--- a/src/Neo.IO/Hashing/Murmur32.cs
+++ b/src/Neo.IO/Hashing/Murmur32.cs
@@ -100,7 +100,7 @@ public override void Append(ReadOnlySpan source)
var k = BitConverter.ToUInt32(_buffer, 0);
ProcessBlock(k);
_bufferLength = 0;
- source = source.Slice(needed);
+ source = source[needed..];
}
}
@@ -109,7 +109,7 @@ public override void Append(ReadOnlySpan source)
{
var k = BitConverter.ToUInt32(source);
ProcessBlock(k);
- source = source.Slice(4);
+ source = source[4..];
}
// Save any remaining bytes for the next Append call or Finalize
diff --git a/src/Neo.Platform.Hosting/BlockchainStoreNames.cs b/src/Neo.Platform.Hosting/BlockchainStoreNames.cs
new file mode 100644
index 0000000..bc8c31d
--- /dev/null
+++ b/src/Neo.Platform.Hosting/BlockchainStoreNames.cs
@@ -0,0 +1,39 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Configuration;
+
+namespace Neo.Platform.Hosting
+{
+ public static class BlockchainStoreNames
+ {
+ public static readonly string SectionKey = "Store";
+
+ public static readonly string DatabasePathKey = $"{SectionKey}:{nameof(BlockchainStoreOptions.DatabasePath)}";
+ public static readonly string CreateIfMissingKey = $"{SectionKey}:{nameof(BlockchainStoreOptions.CreateIfMissing)}";
+
+ public static readonly string BackupSectionKey = $"{SectionKey}:Backup";
+
+ public static readonly string BackupPathKey = $"{BackupSectionKey}:{nameof(BlockchainBackupOptions.BackupPath)}";
+ public static readonly string MaxBackupsKey = $"{BackupSectionKey}:{nameof(BlockchainBackupOptions.MaxBackups)}";
+ }
+}
diff --git a/src/Neo.Platform.Hosting/Factory/NeoHostingFactory.cs b/src/Neo.Platform.Hosting/Factory/NeoHostingFactory.cs
index f4b0ace..e9256c1 100644
--- a/src/Neo.Platform.Hosting/Factory/NeoHostingFactory.cs
+++ b/src/Neo.Platform.Hosting/Factory/NeoHostingFactory.cs
@@ -42,7 +42,7 @@ public static FileInfo ResolveFileName(string filename, string rootPath)
return new(Path.Combine(rootPath, filename));
}
- public static Mutex CreateMutex(string? name = null)
+ public static Mutex CreateMutex(string? name = default)
{
if (string.IsNullOrEmpty(name))
name = Path.Combine(s_globalString, Path.GetRandomFileName());
diff --git a/src/Neo.Platform.Hosting/Logging/NeoPlatformLogger.cs b/src/Neo.Platform.Hosting/Logging/NeoPlatformLogger.cs
index e04d3f8..7272c7e 100644
--- a/src/Neo.Platform.Hosting/Logging/NeoPlatformLogger.cs
+++ b/src/Neo.Platform.Hosting/Logging/NeoPlatformLogger.cs
@@ -26,21 +26,15 @@
namespace Neo.Platform.Hosting.Logging
{
- internal sealed class NeoPlatformLogger : ILogger
+ internal sealed class NeoPlatformLogger(
+ string name,
+ Func config) : ILogger
{
private DateTime DateTimeNow => _getConfig().UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now;
private bool ShowExceptionStackTrace => _getConfig().ShowExceptionStackTrace;
- private readonly string _name;
- private readonly Func _getConfig;
-
- public NeoPlatformLogger(
- string name,
- Func config)
- {
- _name = name;
- _getConfig = config;
- }
+ private readonly string _name = name;
+ private readonly Func _getConfig = config;
public IDisposable? BeginScope(TState state) where TState : notnull =>
default!;
diff --git a/src/Neo.Platform.Hosting/NeoPlatformLifetime.cs b/src/Neo.Platform.Hosting/NeoPlatformLifetime.cs
index 45c2d80..e8c02ad 100644
--- a/src/Neo.Platform.Hosting/NeoPlatformLifetime.cs
+++ b/src/Neo.Platform.Hosting/NeoPlatformLifetime.cs
@@ -30,33 +30,24 @@
namespace Neo.Platform.Hosting
{
- public class NeoPlatformLifetime : IHostLifetime, IDisposable
+ public class NeoPlatformLifetime(
+ IOptions options,
+ IHostEnvironment environment,
+ IHostApplicationLifetime applicationLifetime,
+ ILoggerFactory? loggerFactory = default) : IHostLifetime, IDisposable
{
- public NeoPlatformLifetimeOptions Options { get; }
- public IHostEnvironment Environment { get; }
- public IHostApplicationLifetime ApplicationLifetime { get; }
-
- private CancellationTokenRegistration _appStartedReg;
- private CancellationTokenRegistration _appStoppingReg;
-
- private readonly ILogger _logger;
-
- public NeoPlatformLifetime(
- IOptions options,
- IHostEnvironment environment,
- IHostApplicationLifetime applicationLifetime,
- ILoggerFactory? loggerFactory = default)
- {
- Options = options?.Value ??
+ public NeoPlatformLifetimeOptions Options { get; } = options?.Value ??
throw new ArgumentNullException(nameof(options));
- Environment = environment ??
+ public IHostEnvironment Environment { get; } = environment ??
throw new ArgumentNullException(nameof(environment));
- ApplicationLifetime = applicationLifetime ??
+ public IHostApplicationLifetime ApplicationLifetime { get; } = applicationLifetime ??
throw new ArgumentNullException(nameof(applicationLifetime));
- _logger = (loggerFactory ?? NullLoggerFactory.Instance)
- .CreateLogger("Neo.Hosting.Lifetime");
- }
+ private CancellationTokenRegistration _appStartedReg;
+ private CancellationTokenRegistration _appStoppingReg;
+
+ private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance)
+ .CreateLogger("Neo.Platform.Hosting.Lifetime");
public void Dispose()
{
diff --git a/src/Neo.Platform.Hosting/Provider/NeoEnvironmentConfigurationProvider.cs b/src/Neo.Platform.Hosting/Provider/NeoEnvironmentConfigurationProvider.cs
index ac40e2e..18034a3 100644
--- a/src/Neo.Platform.Hosting/Provider/NeoEnvironmentConfigurationProvider.cs
+++ b/src/Neo.Platform.Hosting/Provider/NeoEnvironmentConfigurationProvider.cs
@@ -27,6 +27,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.IO;
namespace Neo.Platform.Hosting.Provider
{
@@ -46,8 +47,10 @@ public NeoEnvironmentConfigurationProvider(
// ==================================
// Hosting Environment
+ var contentRootDirectory = Environment.CurrentDirectory;
+
Data.Add(HostDefaults.EnvironmentKey, NeoHostingEnvironmentNames.LocalNet);
- Data.Add(HostDefaults.ContentRootKey, Environment.CurrentDirectory);
+ Data.Add(HostDefaults.ContentRootKey, contentRootDirectory);
// Protocol Configuration
var protocolNetwork = NeoHostingFactory.GetDevNetwork(0);
@@ -60,6 +63,14 @@ public NeoEnvironmentConfigurationProvider(
Data.Add(ProtocolSettingNames.MaxTraceableBlocksKey, "2102400");
Data.Add(ProtocolSettingNames.InitialGasDistributionKey, "5200000000000000");
+ // Store Configuration
+ Data.Add(BlockchainStoreNames.DatabasePathKey, Path.Combine(contentRootDirectory, "data", "chain"));
+ Data.Add(BlockchainStoreNames.CreateIfMissingKey, bool.TrueString);
+
+ // Backup Configuration
+ Data.Add(BlockchainStoreNames.BackupPathKey, Path.Combine(contentRootDirectory, "data", "backups"));
+ Data.Add(BlockchainStoreNames.MaxBackupsKey, "3");
+
// ==================================
// * End of Initial Defaults *
// ==================================
diff --git a/src/Neo.VM/Core/StackFrame.cs b/src/Neo.VM/Core/StackFrame.cs
index e1bb67f..69a1b21 100644
--- a/src/Neo.VM/Core/StackFrame.cs
+++ b/src/Neo.VM/Core/StackFrame.cs
@@ -26,7 +26,7 @@
namespace Neo.VM.Core
{
- public class StackFrame : IDisposable
+ public class StackFrame(StackFrame? parent = default) : IDisposable
{
///
/// The evaluation stack for this frame
@@ -53,18 +53,13 @@ public class StackFrame : IDisposable
///
/// Parent frame (for nested calls)
///
- public StackFrame? Parent { get; }
+ public StackFrame? Parent { get; } = parent;
///
/// Whether this frame is currently executing
///
public bool IsActive { get; private set; } = true;
- public StackFrame(StackFrame? parent = default)
- {
- Parent = parent;
- }
-
public void Dispose()
{
Cleanup();
diff --git a/src/Neo.VM/Extensions/DictionaryExtensions.cs b/src/Neo.VM/Extensions/DictionaryExtensions.cs
index 3ebe666..f727678 100644
--- a/src/Neo.VM/Extensions/DictionaryExtensions.cs
+++ b/src/Neo.VM/Extensions/DictionaryExtensions.cs
@@ -29,12 +29,9 @@ namespace Neo.VM.Extensions
public static class DictionaryExtensions
{
public static VMMap ToStackItem(this IDictionary source) =>
- new
- (
- (IList)source.Keys
- .Select(s => s.ToStackItem())
- .Zip(source.Values.Select(s => s.ToStackItem()))
- .ToList()
- );
+ new(
+ source.Select(static kv => new KeyValuePair(
+ kv.Key.ToStackItem(),
+ kv.Value.ToStackItem())));
}
}
diff --git a/src/Neo.VM/Types/VMArray.cs b/src/Neo.VM/Types/VMArray.cs
index d6d3bc1..5c1186e 100644
--- a/src/Neo.VM/Types/VMArray.cs
+++ b/src/Neo.VM/Types/VMArray.cs
@@ -30,7 +30,7 @@
namespace Neo.VM.Types
{
- public class VMArray : VMObject, IEquatable, IEnumerable, IEnumerable, ICollection, IReadOnlyCollection, IList, IReadOnlyList
+ public class VMArray(IEnumerable items, bool isReadonly) : VMObject, IEquatable, IEnumerable, IEnumerable, ICollection, IReadOnlyCollection, IList, IReadOnlyList
{
public override VMObjectType Type => VMObjectType.Array;
@@ -38,9 +38,9 @@ public class VMArray : VMObject, IEquatable, IEnumerable, IEn
public bool IsReadOnly => _isReadOnly;
- private bool _isReadOnly = false;
+ private bool _isReadOnly = isReadonly;
- protected readonly List Array;
+ protected readonly List Array = [.. items];
public VMArray() : this(false) { }
@@ -48,12 +48,6 @@ public VMArray(bool isReadonly) : this([], isReadonly) { }
public VMArray(IEnumerable items) : this(items, false) { }
- public VMArray(IEnumerable items, bool isReadonly)
- {
- Array = [.. items];
- _isReadOnly = isReadonly;
- }
-
public VMObject this[int index]
{
get => Array[index];
diff --git a/src/Neo.VM/Types/VMBoolean.cs b/src/Neo.VM/Types/VMBoolean.cs
index 77300be..d809cad 100644
--- a/src/Neo.VM/Types/VMBoolean.cs
+++ b/src/Neo.VM/Types/VMBoolean.cs
@@ -28,16 +28,11 @@
namespace Neo.VM.Types
{
- public class VMBoolean : VMObject, IEquatable
+ public class VMBoolean(bool value) : VMObject, IEquatable
{
public override VMObjectType Type => VMObjectType.Boolean;
- private readonly bool _value = false;
-
- public VMBoolean(bool value)
- {
- _value = value;
- }
+ private readonly bool _value = value;
public bool Equals(VMBoolean? other)
{
diff --git a/src/Neo.VM/Types/VMInteropInterface.cs b/src/Neo.VM/Types/VMInteropInterface.cs
index a3d7694..25d00fd 100644
--- a/src/Neo.VM/Types/VMInteropInterface.cs
+++ b/src/Neo.VM/Types/VMInteropInterface.cs
@@ -30,12 +30,12 @@
namespace Neo.VM.Types
{
- public class VMInteropInterface : VMObject, IEquatable
+ public class VMInteropInterface(object obj, string name) : VMObject, IEquatable
{
public override VMObjectType Type => VMObjectType.Interop;
- private readonly object _underlyingObject = new();
- private readonly string _interfaceName = string.Empty;
+ private readonly object _underlyingObject = obj;
+ private readonly string _interfaceName = name;
public object UnderlyingObject => _underlyingObject;
public string InterfaceName => _interfaceName;
@@ -44,12 +44,6 @@ public VMInteropInterface() : this(new()) { }
public VMInteropInterface(object obj) : this(obj, obj.GetType().Name) { }
- public VMInteropInterface(object obj, string name)
- {
- _underlyingObject = obj;
- _interfaceName = name;
- }
-
public bool Equals(VMInteropInterface? other)
{
if (ReferenceEquals(other, this)) return true;
@@ -87,7 +81,7 @@ public override VMObject Clone()
protected override ReadOnlySpan ComputeSpan(HashSet visited)
{
var ptr = nint.Zero;
- byte[] bytes = [];
+ byte[] bytes;
try
{
var size = Marshal.SizeOf(_underlyingObject);
diff --git a/tests/Neo.Core.Tests/Cryptography/ECC/UT_ECCurve.cs b/tests/Neo.Core.Tests/Cryptography/ECC/UT_ECCurve.cs
index 676d6ca..82fdf86 100644
--- a/tests/Neo.Core.Tests/Cryptography/ECC/UT_ECCurve.cs
+++ b/tests/Neo.Core.Tests/Cryptography/ECC/UT_ECCurve.cs
@@ -40,7 +40,7 @@ public void TestSecP256r1CompressionPoint()
var expectedCompressedPublicKeyString = "036b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296";
var expectedCompressedPublicKeyBytes = Convert.FromHexString(expectedCompressedPublicKeyString);
- var actualCompressedPublicKeyBytes = ECCurve.SecP256r1.CompressPoint(expectedUncompressedPublicKeyBytes);
+ var actualCompressedPublicKeyBytes = ECCurve.CompressPoint(expectedUncompressedPublicKeyBytes);
var actualCompressedPublicKeyString = Convert.ToHexStringLower(actualCompressedPublicKeyBytes);
var actualUncompressedPublicKeyBytes = ECCurve.SecP256r1.DecompressPoint(expectedCompressedPublicKeyBytes);
diff --git a/tests/Neo.Core.Tests/Extensions/UT_ByteExtensions.cs b/tests/Neo.Core.Tests/Extensions/UT_ByteExtensions.cs
index 3306b4a..085e5c0 100644
--- a/tests/Neo.Core.Tests/Extensions/UT_ByteExtensions.cs
+++ b/tests/Neo.Core.Tests/Extensions/UT_ByteExtensions.cs
@@ -54,7 +54,7 @@ public void TestXor()
Assert.HasCount(ushort.MaxValue, actualXorByte3);
Assert.AreSequenceEqual(expectedBytes3, actualXorByte3);
- Assert.ThrowsExactly(() => new byte[0].Xor(new byte[ushort.MaxValue]));
+ Assert.ThrowsExactly(() => Array.Empty().Xor(new byte[ushort.MaxValue]));
}
[TestMethod]
diff --git a/tests/Neo.Core.Tests/Net/Message/UT_NodeCapabilityMessage.cs b/tests/Neo.Core.Tests/Net/Message/UT_NodeCapabilityMessage.cs
new file mode 100644
index 0000000..a3bc2b5
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/Message/UT_NodeCapabilityMessage.cs
@@ -0,0 +1,105 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System.IO;
+
+namespace Neo.Core.Tests.Net.Message
+{
+ [TestClass]
+ public class UT_NodeCapabilityMessage
+ {
+ [TestMethod]
+ public void TestServerCapabilityRoundTrip()
+ {
+ var expected = new ServerCapabilityMessage(port: 10333);
+ var bytes = expected.ToArray();
+
+ Assert.HasCount(expected.Size, bytes);
+
+ using var ms = new MemoryStream(bytes);
+ var actual = NodeCapabilityMessage.DeserializeFrom(ms);
+
+ Assert.IsInstanceOfType(actual);
+ Assert.AreEqual(NodeCapabilityType.TcpServer, actual.Type);
+ Assert.AreEqual((ushort)10333, ((ServerCapabilityMessage)actual).Port);
+ Assert.AreEqual(bytes.Length, ms.Position);
+ }
+
+ [TestMethod]
+ public void TestFullNodeCapabilityRoundTrip()
+ {
+ var expected = new FullNodeCapabilityMessage(startHeight: 1_000_000u);
+ var bytes = expected.ToArray();
+
+ Assert.HasCount(expected.Size, bytes);
+
+ using var ms = new MemoryStream(bytes);
+ var actual = NodeCapabilityMessage.DeserializeFrom(ms);
+
+ Assert.IsInstanceOfType(actual);
+ Assert.AreEqual(1_000_000u, ((FullNodeCapabilityMessage)actual).StartHeight);
+ Assert.AreEqual(bytes.Length, ms.Position);
+ }
+
+ [TestMethod]
+ public void TestArchivalNodeCapabilityRoundTrip()
+ {
+ var expected = new ArchivalNodeCapabilityMessage();
+ var bytes = expected.ToArray();
+
+ using var ms = new MemoryStream(bytes);
+ var actual = NodeCapabilityMessage.DeserializeFrom(ms);
+
+ Assert.IsInstanceOfType(actual);
+ Assert.AreEqual(NodeCapabilityType.ArchivalNode, actual.Type);
+ Assert.AreEqual(bytes.Length, ms.Position);
+ }
+
+ [TestMethod]
+ public void TestDisableCompressionCapabilityRoundTrip()
+ {
+ var expected = new DisableCompressionCapabilityMessage();
+ var bytes = expected.ToArray();
+
+ using var ms = new MemoryStream(bytes);
+ var actual = NodeCapabilityMessage.DeserializeFrom(ms);
+
+ Assert.IsInstanceOfType(actual);
+ Assert.AreEqual(NodeCapabilityType.DisableCompression, actual.Type);
+ Assert.AreEqual(bytes.Length, ms.Position);
+ }
+
+ [TestMethod]
+ public void TestStandaloneDeserialize()
+ {
+ // INeoSerializable path still works: type byte + payload.
+ var expected = new ServerCapabilityMessage(port: 20333);
+ var actual = expected.ToArray().AsSerializable();
+
+ Assert.AreEqual(expected.Port, actual.Port);
+ Assert.AreEqual(expected.Type, actual.Type);
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/Message/UT_PingMessage.cs b/tests/Neo.Core.Tests/Net/Message/UT_PingMessage.cs
new file mode 100644
index 0000000..d2f763d
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/Message/UT_PingMessage.cs
@@ -0,0 +1,69 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+
+namespace Neo.Core.Tests.Net.Message
+{
+ [TestClass]
+ public class UT_PingMessage
+ {
+ [TestMethod]
+ public void TestSize()
+ {
+ var ping = PingMessage.Create(lastBlockIndex: 100, nonce: 7);
+ Assert.AreEqual(12, ping.Size);
+ Assert.AreEqual(ping.ToArray().Length, ping.Size);
+ }
+
+ [TestMethod]
+ public void TestSerializeAndDeserialize()
+ {
+ var expected = PingMessage.Create(lastBlockIndex: 1_234_567u, nonce: 99u);
+ var actual = expected.ToArray().AsSerializable();
+
+ Assert.AreEqual(expected.LastBlockIndex, actual.LastBlockIndex);
+ Assert.AreEqual(expected.Timestamp, actual.Timestamp);
+ Assert.AreEqual(expected.Nonce, actual.Nonce);
+ }
+
+ [TestMethod]
+ public void TestProtocolMessageRoundTrip()
+ {
+ var ping = PingMessage.Create(50, 123);
+ var frame = ProtocolMessage.Create(ProtocolMessageCommand.Ping, ping);
+ var bytes = frame.ToArray();
+
+ var consumed = ProtocolMessage.TryRead(bytes, out var message);
+ Assert.AreEqual(bytes.Length, consumed);
+ Assert.IsNotNull(message);
+ Assert.AreEqual(ProtocolMessageCommand.Ping, message.Command);
+ Assert.IsInstanceOfType(message.Message);
+
+ var actual = (PingMessage)message.Message;
+ Assert.AreEqual(50u, actual.LastBlockIndex);
+ Assert.AreEqual(123u, actual.Nonce);
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/Message/UT_ProtocolMessage.cs b/tests/Neo.Core.Tests/Net/Message/UT_ProtocolMessage.cs
new file mode 100644
index 0000000..f6851bc
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/Message/UT_ProtocolMessage.cs
@@ -0,0 +1,250 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using Neo.Core.Serialization;
+using System;
+using System.Buffers.Binary;
+using System.IO;
+
+namespace Neo.Core.Tests.Net.Message
+{
+ [TestClass]
+ public class UT_ProtocolMessage
+ {
+ [TestMethod]
+ public void TestTryReadIncompleteHeader()
+ {
+ // Only flags + command; missing the first VarSize byte.
+ ReadOnlySpan data = [(byte)ProtocolMessageFlags.None, (byte)ProtocolMessageCommand.VersionAck];
+
+ var consumed = ProtocolMessage.TryRead(data, out var message);
+
+ Assert.AreEqual(0, consumed);
+ Assert.IsNull(message);
+ }
+
+ [TestMethod]
+ public void TestTryReadIncompletePayload()
+ {
+ // Declares 5 payload bytes, supplies only 2.
+ ReadOnlySpan data =
+ [
+ (byte)ProtocolMessageFlags.None,
+ (byte)ProtocolMessageCommand.Version,
+ 0x05, // compact length = 5
+ 0x01, 0x02, // only 2 of 5
+ ];
+
+ var consumed = ProtocolMessage.TryRead(data, out var message);
+
+ Assert.AreEqual(0, consumed);
+ Assert.IsNull(message);
+ }
+
+ [TestMethod]
+ public void TestTryReadEmptyPayloadVersionAck()
+ {
+ // Neo Verack has no payload: 00 01 00
+ ReadOnlySpan data =
+ [
+ (byte)ProtocolMessageFlags.None,
+ (byte)ProtocolMessageCommand.VersionAck,
+ 0x00, // length 0
+ ];
+
+ var consumed = ProtocolMessage.TryRead(data, out var message);
+
+ Assert.AreEqual(3, consumed);
+ Assert.IsNotNull(message);
+ Assert.AreEqual(ProtocolMessageFlags.None, message.Flags);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, message.Command);
+ Assert.IsFalse(message.Compressed);
+ Assert.HasCount(0, message.PayloadBytes);
+ Assert.IsNull(message.Message);
+ }
+
+ [TestMethod]
+ public void TestTryReadLeavesTrailingBytes()
+ {
+ // Framing only: use a command with no typed payload decoder yet.
+ // One full frame (3 header + 2 payload) + leftover bytes for the next frame.
+ byte[] buffer =
+ [
+ (byte)ProtocolMessageFlags.None,
+ (byte)ProtocolMessageCommand.GetAddress,
+ 0x02, // length 2
+ 0xAA, 0xBB, // payload
+ 0xFF, 0xEE, // NOT part of this frame
+ ];
+
+ var consumed = ProtocolMessage.TryRead(buffer, out var message);
+
+ Assert.AreEqual(5, consumed);
+ Assert.IsNotNull(message);
+ Assert.AreEqual(ProtocolMessageCommand.GetAddress, message.Command);
+ Assert.AreSequenceEqual(new byte[] { 0xAA, 0xBB }, message.PayloadBytes.ToArray());
+ Assert.IsNull(message.Message);
+
+ // Caller would next call TryRead on buffer[consumed..] for the next frame.
+ var remaining = buffer.AsSpan(consumed);
+ Assert.AreSequenceEqual(new byte[] { 0xFF, 0xEE }, remaining.ToArray());
+ }
+
+ [TestMethod]
+ public void TestTryReadUInt16LengthPrefix()
+ {
+ // Length uses 0xFD marker + 2 LE bytes (length = 0x0100 = 256).
+ var payload = new byte[256];
+ payload[0] = 0x42;
+ payload[^1] = 0x24;
+
+ var buffer = new byte[5 + payload.Length];
+ buffer[0] = (byte)ProtocolMessageFlags.None;
+ buffer[1] = (byte)ProtocolMessageCommand.Address;
+ buffer[2] = 0xfd;
+ BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(3, 2), 256);
+ payload.CopyTo(buffer.AsSpan(5));
+
+ var consumed = ProtocolMessage.TryRead(buffer, out var message);
+
+ Assert.AreEqual(5 + 256, consumed);
+ Assert.IsNotNull(message);
+ Assert.HasCount(256, message.PayloadBytes);
+ Assert.AreEqual(0x42, message.PayloadBytes.Span[0]);
+ Assert.AreEqual(0x24, message.PayloadBytes.Span[^1]);
+ }
+
+ [TestMethod]
+ public void TestTryReadPayloadLengthExceedsMax()
+ {
+ // 0xFE + 4-byte length claiming PayloadMaxSize + 1.
+ var buffer = new byte[7];
+ buffer[0] = (byte)ProtocolMessageFlags.None;
+ buffer[1] = (byte)ProtocolMessageCommand.Block;
+ buffer[2] = 0xfe;
+ BinaryPrimitives.WriteUInt32LittleEndian(
+ buffer.AsSpan(3, 4),
+ (uint)ProtocolMessage.PayloadMaxSize + 1);
+
+ Assert.ThrowsExactly(() =>
+ ProtocolMessage.TryRead(buffer, out _));
+ }
+
+ [TestMethod]
+ public void TestCreateVersionAckRoundTrip()
+ {
+ var expected = ProtocolMessage.Create(ProtocolMessageCommand.VersionAck);
+ var bytes = expected.ToArray();
+
+ var consumed = ProtocolMessage.TryRead(bytes, out var actual);
+
+ Assert.AreEqual(bytes.Length, consumed);
+ Assert.IsNotNull(actual);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, actual.Command);
+ Assert.AreEqual(ProtocolMessageFlags.None, actual.Flags);
+ Assert.HasCount(0, actual.PayloadBytes);
+ Assert.IsNull(actual.Message);
+ }
+
+ [TestMethod]
+ public void TestCreateVersionRoundTrip()
+ {
+ var version = VersionMessage.Create(network: 0x334F454E, nonce: 12345u);
+ var expected = ProtocolMessage.Create(ProtocolMessageCommand.Version, version);
+ var bytes = expected.ToArray();
+
+ var consumed = ProtocolMessage.TryRead(bytes, out var actual);
+
+ Assert.AreEqual(bytes.Length, consumed);
+ Assert.IsNotNull(actual);
+ Assert.AreEqual(ProtocolMessageCommand.Version, actual.Command);
+ Assert.IsFalse(actual.Compressed);
+ Assert.IsInstanceOfType(actual.Message);
+
+ var actualVersion = (VersionMessage)actual.Message;
+ Assert.AreEqual(version.Network, actualVersion.Network);
+ Assert.AreEqual(version.Nonce, actualVersion.Nonce);
+ Assert.AreEqual(version.UserAgent, actualVersion.UserAgent);
+ }
+
+ [TestMethod]
+ public void TestCreateDoesNotCompressVersion()
+ {
+ // Version is not in Neo's compressible command list.
+ var largePayload = new RawBytesPayload(new byte[512]);
+ var message = ProtocolMessage.Create(ProtocolMessageCommand.Version, largePayload);
+
+ Assert.IsFalse(message.Compressed);
+ Assert.HasCount(512, message.PayloadBytes);
+ }
+
+ [TestMethod]
+ public void TestCreateCompressesEligiblePayload()
+ {
+ // Block is compressible; zeros compress well past the 64-byte threshold.
+ var largePayload = new RawBytesPayload(new byte[512]);
+ var message = ProtocolMessage.Create(ProtocolMessageCommand.Block, largePayload);
+
+ Assert.IsTrue(message.Compressed);
+ Assert.IsLessThan(512 - 64, message.PayloadBytes.Length);
+ }
+
+ [TestMethod]
+ public void TestToArrayWithoutCompression()
+ {
+ var largePayload = new RawBytesPayload(new byte[512]);
+ var message = ProtocolMessage.Create(ProtocolMessageCommand.Block, largePayload);
+ Assert.IsTrue(message.Compressed);
+
+ var compressedBytes = message.ToArray(allowCompression: true);
+ var uncompressedBytes = message.ToArray(allowCompression: false);
+
+ Assert.IsGreaterThan(0, compressedBytes.Length);
+ Assert.AreNotEqual(compressedBytes.Length, uncompressedBytes.Length);
+
+ var consumed = ProtocolMessage.TryRead(uncompressedBytes, out var actual);
+ Assert.AreEqual(uncompressedBytes.Length, consumed);
+ Assert.IsNotNull(actual);
+ Assert.IsFalse(actual.Compressed);
+ Assert.HasCount(512, actual.PayloadBytes);
+ }
+
+ ///
+ /// Test-only payload: writes raw bytes with no extra framing.
+ ///
+ private sealed class RawBytesPayload(byte[] bytes) : INeoSerializable
+ {
+ private readonly byte[] _bytes = bytes;
+
+ public int Size => _bytes.Length;
+
+ public void Serialize(Stream writer) =>
+ writer.Write(_bytes, 0, _bytes.Length);
+
+ public void Deserialize(Stream reader) =>
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/Message/UT_VersionMessage.cs b/tests/Neo.Core.Tests/Net/Message/UT_VersionMessage.cs
new file mode 100644
index 0000000..18cb3a9
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/Message/UT_VersionMessage.cs
@@ -0,0 +1,167 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Extensions;
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System;
+
+namespace Neo.Core.Tests.Net.Message
+{
+ [TestClass]
+ public class UT_VersionMessage
+ {
+ [TestMethod]
+ public void TestSize()
+ {
+ var actualVersionMessage = new VersionMessage();
+
+ Assert.AreEqual(actualVersionMessage.ToArray().Length, actualVersionMessage.Size);
+ }
+
+ [TestMethod]
+ public void TestSizeWithCapabilities()
+ {
+ var version = VersionMessage.Create(
+ network: 0x334F454E,
+ nonce: 1u,
+ new ServerCapabilityMessage(10333),
+ new FullNodeCapabilityMessage(42),
+ new ArchivalNodeCapabilityMessage());
+
+ Assert.AreEqual(version.ToArray().Length, version.Size);
+ }
+
+ [TestMethod]
+ public void TestCreate()
+ {
+ var expectedVersionMessage = new VersionMessage();
+
+ var actualVersionMessage = VersionMessage.Create(
+ expectedVersionMessage.Network,
+ expectedVersionMessage.Nonce,
+ expectedVersionMessage.Capabilities);
+
+ Assert.AreEqual(expectedVersionMessage.Network, actualVersionMessage.Network);
+ Assert.AreEqual(expectedVersionMessage.Version, actualVersionMessage.Version);
+ Assert.AreEqual(expectedVersionMessage.Timestamp, actualVersionMessage.Timestamp);
+ Assert.AreEqual(expectedVersionMessage.Nonce, actualVersionMessage.Nonce);
+ Assert.AreEqual(expectedVersionMessage.AllowCompression, actualVersionMessage.AllowCompression);
+ Assert.AreEqual(expectedVersionMessage.UserAgent, actualVersionMessage.UserAgent);
+ Assert.AreSequenceEqual(expectedVersionMessage.Capabilities, actualVersionMessage.Capabilities);
+ }
+
+ [TestMethod]
+ public void TestSerializeAndDeserialize()
+ {
+ var expectedVersionMessage = new VersionMessage();
+ var expectedVersionBytes = expectedVersionMessage.ToArray();
+
+ var actualVersionMessage = expectedVersionBytes.AsSerializable();
+
+ Assert.AreEqual(expectedVersionMessage.Network, actualVersionMessage.Network);
+ Assert.AreEqual(expectedVersionMessage.Version, actualVersionMessage.Version);
+ Assert.AreEqual(expectedVersionMessage.Timestamp, actualVersionMessage.Timestamp);
+ Assert.AreEqual(expectedVersionMessage.Nonce, actualVersionMessage.Nonce);
+ Assert.AreEqual(expectedVersionMessage.AllowCompression, actualVersionMessage.AllowCompression);
+ Assert.AreEqual(expectedVersionMessage.UserAgent, actualVersionMessage.UserAgent);
+ Assert.AreSequenceEqual(expectedVersionMessage.Capabilities, actualVersionMessage.Capabilities);
+ }
+
+ [TestMethod]
+ public void TestSerializeAndDeserializeWithCapabilities()
+ {
+ var expected = VersionMessage.Create(
+ network: 0x334F454E,
+ nonce: 99u,
+ new ServerCapabilityMessage(10333),
+ new FullNodeCapabilityMessage(1_234_567u),
+ new ArchivalNodeCapabilityMessage());
+
+ var actual = expected.ToArray().AsSerializable();
+
+ Assert.AreEqual(expected.Network, actual.Network);
+ Assert.AreEqual(expected.Nonce, actual.Nonce);
+ Assert.AreEqual(expected.UserAgent, actual.UserAgent);
+ Assert.IsTrue(actual.AllowCompression);
+ Assert.HasCount(3, actual.Capabilities);
+
+ Assert.IsInstanceOfType(actual.Capabilities[0]);
+ Assert.AreEqual((ushort)10333, ((ServerCapabilityMessage)actual.Capabilities[0]).Port);
+
+ Assert.IsInstanceOfType(actual.Capabilities[1]);
+ Assert.AreEqual(1_234_567u, ((FullNodeCapabilityMessage)actual.Capabilities[1]).StartHeight);
+
+ Assert.IsInstanceOfType(actual.Capabilities[2]);
+ }
+
+ [TestMethod]
+ public void TestDisableCompressionCapability()
+ {
+ var version = VersionMessage.Create(
+ network: 1u,
+ nonce: 1u,
+ new DisableCompressionCapabilityMessage());
+
+ Assert.IsFalse(version.AllowCompression);
+
+ var actual = version.ToArray().AsSerializable();
+ Assert.IsFalse(actual.AllowCompression);
+ }
+
+ [TestMethod]
+ public void TestDuplicateCapabilityTypesThrow()
+ {
+ var version = VersionMessage.Create(
+ network: 1u,
+ nonce: 1u,
+ new ServerCapabilityMessage(1),
+ new ServerCapabilityMessage(2));
+
+ Assert.ThrowsExactly(() =>
+ version.ToArray().AsSerializable());
+ }
+
+ [TestMethod]
+ public void TestVersionInsideProtocolMessage()
+ {
+ var version = VersionMessage.Create(
+ network: 0x334F454E,
+ nonce: 7u,
+ new FullNodeCapabilityMessage(100),
+ new ServerCapabilityMessage(10333));
+
+ var frame = ProtocolMessage.Create(ProtocolMessageCommand.Version, version);
+ var bytes = frame.ToArray();
+
+ var consumed = ProtocolMessage.TryRead(bytes, out var message);
+ Assert.AreEqual(bytes.Length, consumed);
+ Assert.IsNotNull(message);
+ Assert.IsInstanceOfType(message.Message);
+
+ var actual = (VersionMessage)message.Message;
+ Assert.AreEqual(version.Network, actual.Network);
+ Assert.AreEqual(version.Nonce, actual.Nonce);
+ Assert.HasCount(2, actual.Capabilities);
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/UT_NodeConnection.cs b/tests/Neo.Core.Tests/Net/UT_NodeConnection.cs
new file mode 100644
index 0000000..3ba7259
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/UT_NodeConnection.cs
@@ -0,0 +1,128 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+
+namespace Neo.Core.Tests.Net
+{
+ [TestClass]
+ public class UT_NodeConnection
+ {
+ [TestMethod]
+ public void TestTryReadFrameIncomplete()
+ {
+ // Flags + command only — missing VarSize length byte.
+ ReadOnlySequence buffer = new([(byte)ProtocolMessageFlags.None, (byte)ProtocolMessageCommand.VersionAck]);
+
+ var ok = NodeConnection.TryReadFrame(ref buffer, out var message);
+
+ Assert.IsFalse(ok);
+ Assert.IsNull(message);
+ Assert.AreEqual(2, buffer.Length);
+ }
+
+ [TestMethod]
+ public void TestTryReadFrameOneMessage()
+ {
+ var bytes = ProtocolMessage.Create(ProtocolMessageCommand.VersionAck).ToArray();
+ ReadOnlySequence buffer = new(bytes);
+
+ var ok = NodeConnection.TryReadFrame(ref buffer, out var message);
+
+ Assert.IsTrue(ok);
+ Assert.IsNotNull(message);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, message.Command);
+ Assert.AreEqual(0, buffer.Length);
+ }
+
+ [TestMethod]
+ public void TestTryReadFrameMultipleMessages()
+ {
+ var frame1 = ProtocolMessage.Create(ProtocolMessageCommand.VersionAck).ToArray();
+ var frame2 = ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(0x334F454E, 42u)).ToArray();
+
+ var combined = new byte[frame1.Length + frame2.Length];
+ frame1.CopyTo(combined.AsSpan());
+ frame2.CopyTo(combined.AsSpan(frame1.Length));
+
+ ReadOnlySequence buffer = new(combined);
+ var messages = new List();
+
+ while (NodeConnection.TryReadFrame(ref buffer, out var message))
+ messages.Add(message!);
+
+ Assert.HasCount(2, messages);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, messages[0].Command);
+ Assert.AreEqual(ProtocolMessageCommand.Version, messages[1].Command);
+ Assert.IsInstanceOfType(messages[1].Message);
+ Assert.AreEqual(0, buffer.Length);
+ }
+
+ [TestMethod]
+ public void TestTryReadFramePartialThenComplete()
+ {
+ var full = ProtocolMessage.Create(ProtocolMessageCommand.VersionAck).ToArray();
+ Assert.IsGreaterThan(2, full.Length);
+
+ // First half only — incomplete.
+ var partial = full.AsSpan(0, full.Length - 1).ToArray();
+ ReadOnlySequence buffer = new(partial);
+
+ Assert.IsFalse(NodeConnection.TryReadFrame(ref buffer, out _));
+ Assert.AreEqual(partial.Length, buffer.Length);
+
+ // Remaining byte arrives — full frame.
+ var complete = new byte[partial.Length + 1];
+ partial.CopyTo(complete.AsSpan());
+ complete[^1] = full[^1];
+ buffer = new ReadOnlySequence(complete);
+
+ Assert.IsTrue(NodeConnection.TryReadFrame(ref buffer, out var message));
+ Assert.IsNotNull(message);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, message.Command);
+ Assert.AreEqual(0, buffer.Length);
+ }
+
+ [TestMethod]
+ public void TestTryReadFrameLeavesTrailingBytes()
+ {
+ var frame = ProtocolMessage.Create(ProtocolMessageCommand.VersionAck).ToArray();
+ var trailing = new byte[] { 0xAA, 0xBB };
+ var combined = new byte[frame.Length + trailing.Length];
+ frame.CopyTo(combined.AsSpan());
+ trailing.CopyTo(combined.AsSpan(frame.Length));
+
+ ReadOnlySequence buffer = new(combined);
+
+ Assert.IsTrue(NodeConnection.TryReadFrame(ref buffer, out var message));
+ Assert.IsNotNull(message);
+ Assert.AreEqual(2, buffer.Length);
+ Assert.AreSequenceEqual(trailing, buffer.ToArray());
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/UT_NodeConnectionConnect.cs b/tests/Neo.Core.Tests/Net/UT_NodeConnectionConnect.cs
new file mode 100644
index 0000000..dde5edf
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/UT_NodeConnectionConnect.cs
@@ -0,0 +1,84 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+namespace Neo.Core.Tests.Net
+{
+ // Socket/listen tests must not run in parallel (assembly uses MethodLevel parallelization).
+ [TestClass]
+ [DoNotParallelize]
+ public class UT_NodeConnectionConnect
+ {
+ public TestContext TestContext { get; set; } = null!;
+
+ [TestMethod]
+ public async Task TestConnectAsyncHandshake()
+ {
+ var settings = new ProtocolSettings { Network = 0x334F454E };
+ var ct = TestContext.CancellationToken;
+
+ await using var server = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ settings);
+ server.Start(backlog: 4);
+
+ await using var client = await NodeConnection.ConnectAsync(
+ server.BoundEndPoint,
+ settings,
+ localNonce: 42,
+ localCapabilities:
+ [
+ new FullNodeCapabilityMessage(10),
+ new ServerCapabilityMessage(20333),
+ ],
+ cancellationToken: ct);
+
+ await client.WaitForHandshakeAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct);
+
+ Assert.IsTrue(client.IsReady);
+ Assert.IsNotNull(client.RemoteVersion);
+ Assert.AreEqual(settings.Network, client.RemoteVersion.Network);
+ Assert.AreEqual(server.Nonce, client.RemoteVersion.Nonce);
+ }
+
+ [TestMethod]
+ public async Task TestConnectAsyncUnreachableThrows()
+ {
+ var settings = new ProtocolSettings { Network = 1 };
+ // Port with nothing listening (best-effort; OS may refuse quickly).
+ var endPoint = new IPEndPoint(IPAddress.Loopback, TestUtilities.GetFreeTcpPort());
+ var ct = TestContext.CancellationToken;
+
+ await Assert.ThrowsExactlyAsync(async () =>
+ {
+ await NodeConnection.ConnectAsync(endPoint, settings, localNonce: 1, cancellationToken: ct)
+ .WaitAsync(TimeSpan.FromSeconds(5), ct);
+ });
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/UT_NodeHandshake.cs b/tests/Neo.Core.Tests/Net/UT_NodeHandshake.cs
new file mode 100644
index 0000000..e14be85
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/UT_NodeHandshake.cs
@@ -0,0 +1,151 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System.Net;
+
+namespace Neo.Core.Tests.Net
+{
+ [TestClass]
+ public class UT_NodeHandshake
+ {
+ private const uint Network = 0x334F454E;
+ private const uint LocalNonce = 100u;
+ private const uint RemoteNonce = 200u;
+
+ [TestMethod]
+ public void TestHappyPath()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+
+ Assert.AreEqual(NodeHandshakeState.WaitingForVersion, handshake.State);
+
+ var versionFrame = ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce, new FullNodeCapabilityMessage(1)));
+
+ var verack = handshake.Process(versionFrame);
+
+ Assert.IsNotNull(verack);
+ Assert.AreEqual(ProtocolMessageCommand.VersionAck, verack.Command);
+ Assert.AreEqual(NodeHandshakeState.WaitingForVerack, handshake.State);
+ Assert.IsNotNull(handshake.RemoteVersion);
+ Assert.AreEqual(RemoteNonce, handshake.RemoteVersion.Nonce);
+ Assert.IsTrue(handshake.RemoteAllowsCompression);
+
+ var reply = handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.VersionAck));
+
+ Assert.IsNull(reply);
+ Assert.IsTrue(handshake.IsReady);
+ Assert.AreEqual(NodeHandshakeState.Ready, handshake.State);
+ }
+
+ [TestMethod]
+ public void TestRejectsNonVersionFirst()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+
+ Assert.ThrowsExactly(() =>
+ handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.VersionAck)));
+ }
+
+ [TestMethod]
+ public void TestRejectsNetworkMismatch()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ var versionFrame = ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(network: 1u, nonce: RemoteNonce));
+
+ Assert.ThrowsExactly(() =>
+ handshake.Process(versionFrame));
+ }
+
+ [TestMethod]
+ public void TestRejectsSelfNonce()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ var versionFrame = ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, LocalNonce));
+
+ Assert.ThrowsExactly(() =>
+ handshake.Process(versionFrame));
+ }
+
+ [TestMethod]
+ public void TestRejectsWrongCommandWhileWaitingVerack()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ handshake.Process(ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce)));
+
+ Assert.ThrowsExactly(() =>
+ handshake.Process(ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce + 1))));
+ }
+
+ [TestMethod]
+ public void TestRejectsHandshakeMessagesAfterReady()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ handshake.Process(ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce)));
+ handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.VersionAck));
+
+ Assert.ThrowsExactly(() =>
+ handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.VersionAck)));
+ }
+
+ [TestMethod]
+ public void TestReadyAllowsOtherCommands()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ handshake.Process(ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce)));
+ handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.VersionAck));
+
+ var reply = handshake.Process(ProtocolMessage.Create(ProtocolMessageCommand.Ping));
+
+ Assert.IsNull(reply);
+ Assert.IsTrue(handshake.IsReady);
+ }
+
+ [TestMethod]
+ public void TestDisableCompressionCapability()
+ {
+ var handshake = new NodeHandshake(Network, LocalNonce);
+ var versionFrame = ProtocolMessage.Create(
+ ProtocolMessageCommand.Version,
+ VersionMessage.Create(Network, RemoteNonce, new DisableCompressionCapabilityMessage()));
+
+ handshake.Process(versionFrame);
+
+ Assert.IsFalse(handshake.RemoteAllowsCompression);
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/Net/UT_NodeServerListener.cs b/tests/Neo.Core.Tests/Net/UT_NodeServerListener.cs
new file mode 100644
index 0000000..9bf4bd3
--- /dev/null
+++ b/tests/Neo.Core.Tests/Net/UT_NodeServerListener.cs
@@ -0,0 +1,177 @@
+// BSD 2-Clause License
+//
+// Copyright (c) 2026, Rapid Loop
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+// list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+// this list of conditions and the following disclaimer in the documentation
+// and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES
+
+using Neo.Core.Net;
+using Neo.Core.Net.Message;
+using System;
+using System.Net;
+using System.Threading.Tasks;
+
+namespace Neo.Core.Tests.Net
+{
+ // Socket/listen tests must not run in parallel (assembly uses MethodLevel parallelization).
+ [TestClass]
+ [DoNotParallelize]
+ public class UT_NodeServerListener
+ {
+ public TestContext TestContext { get; set; } = null!;
+
+ [TestMethod]
+ public async Task TestStartAndDispose()
+ {
+ // Port 0: OS assigns a free port at Bind (avoids GetFreeTcpPort TOCTOU on CI).
+ await using var listener = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ ProtocolSettings.Default);
+
+ listener.Start();
+ Assert.IsTrue(listener.IsActive);
+ Assert.IsGreaterThan(0u, listener.Nonce);
+ Assert.IsGreaterThan(0, listener.BoundEndPoint.Port);
+ }
+
+ [TestMethod]
+ public async Task TestDoubleStartIsIdempotent()
+ {
+ await using var listener = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ ProtocolSettings.Default);
+
+ listener.Start();
+ listener.Start();
+ Assert.IsTrue(listener.IsActive);
+ }
+
+ [TestMethod]
+ public async Task TestInvalidBacklogThrows()
+ {
+ await using var listener = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ ProtocolSettings.Default);
+
+ Assert.ThrowsExactly(() => listener.Start(0));
+ Assert.ThrowsExactly(() => listener.Start(-1));
+ }
+
+ [TestMethod]
+ public async Task TestAcceptHandshakeAndClientRemovedOnDisconnect()
+ {
+ var settings = new ProtocolSettings { Network = 0x334F454E };
+ var ct = TestContext.CancellationToken;
+
+ await using var listener = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ settings);
+ listener.Start(backlog: 8);
+
+ await using var client = await NodeConnection.ConnectAsync(
+ listener.BoundEndPoint,
+ settings,
+ localNonce: listener.Nonce + 1,
+ localCapabilities: [new FullNodeCapabilityMessage(0)],
+ cancellationToken: ct);
+
+ await client.WaitForHandshakeAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct);
+
+ Assert.IsTrue(client.IsReady);
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => listener.Clients.Length == 1, TimeSpan.FromSeconds(5)));
+
+ await client.DisposeAsync();
+
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => listener.Clients.Length == 0, TimeSpan.FromSeconds(5)));
+ }
+
+ [TestMethod]
+ public async Task TestOutboundConnectAsync()
+ {
+ var settings = new ProtocolSettings { Network = 0x334F454E };
+ var ct = TestContext.CancellationToken;
+
+ await using var server = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ settings);
+ server.Start(backlog: 8);
+
+ // Client node may dial without listening, but still uses node identity.
+ await using var clientNode = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ settings);
+
+ await using var outbound = await clientNode.ConnectAsync(server.BoundEndPoint, ct)
+ .WaitAsync(TimeSpan.FromSeconds(5), ct);
+
+ await outbound.WaitForHandshakeAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct);
+
+ Assert.IsTrue(outbound.IsReady);
+ Assert.IsNotNull(outbound.RemoteVersion);
+ Assert.AreEqual(settings.Network, outbound.RemoteVersion.Network);
+ Assert.HasCount(1, clientNode.Clients);
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => server.Clients.Length == 1, TimeSpan.FromSeconds(5)));
+
+ await outbound.DisposeAsync();
+
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => clientNode.Clients.Length == 0, TimeSpan.FromSeconds(5)));
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => server.Clients.Length == 0, TimeSpan.FromSeconds(5)));
+ }
+
+ [TestMethod]
+ public async Task TestPingPongAfterHandshake()
+ {
+ var settings = new ProtocolSettings { Network = 0x334F454E };
+ var ct = TestContext.CancellationToken;
+
+ await using var server = new NodeServerListener(
+ new IPEndPoint(IPAddress.Loopback, 0),
+ settings);
+ server.Start(backlog: 4);
+
+ await using var client = await NodeConnection.ConnectAsync(
+ server.BoundEndPoint,
+ settings,
+ localNonce: server.Nonce + 1,
+ localCapabilities: [new FullNodeCapabilityMessage(5)],
+ cancellationToken: ct);
+
+ client.LocalBlockIndex = 5;
+ await client.WaitForHandshakeAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct);
+
+ Assert.IsTrue(await TestUtilities.WaitForAsync(() => server.Clients.Length == 1, TimeSpan.FromSeconds(5)));
+ var serverPeer = server.Clients[0];
+ serverPeer.LocalBlockIndex = 10;
+
+ var pongReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.MessageReceived += (_, message) =>
+ {
+ if (message.Command == ProtocolMessageCommand.Pong && message.Message is PingMessage pong)
+ pongReceived.TrySetResult(pong);
+ };
+
+ var ping = PingMessage.Create(lastBlockIndex: 5, nonce: 0xABCD1234);
+ await client.SendAsync(ProtocolMessage.Create(ProtocolMessageCommand.Ping, ping), cancellationToken: ct);
+
+ var pongPayload = await pongReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), ct);
+ Assert.AreEqual(0xABCD1234u, pongPayload.Nonce);
+ Assert.AreEqual(10u, pongPayload.LastBlockIndex);
+ Assert.AreEqual(5u, serverPeer.RemoteLastBlockIndex);
+ }
+ }
+}
diff --git a/tests/Neo.Core.Tests/TestUtilities.cs b/tests/Neo.Core.Tests/TestUtilities.cs
index a27ed35..bc6cba6 100644
--- a/tests/Neo.Core.Tests/TestUtilities.cs
+++ b/tests/Neo.Core.Tests/TestUtilities.cs
@@ -23,6 +23,9 @@
using Neo.Core.Blockchain;
using Neo.Core.VM;
using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
namespace Neo.Core.Tests
{
@@ -43,5 +46,34 @@ public static BlockHeader CreateBlockHeader(uint index, UInt256 prevHash) =>
VerificationScript = [(byte)OpCode.PUSH1],
}
};
+
+ ///
+ /// Binds an ephemeral TCP port on loopback and returns it (listener is stopped immediately).
+ ///
+ public static int GetFreeTcpPort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ ///
+ /// Polls until it is true or elapses.
+ ///
+ public static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var start = DateTime.UtcNow;
+ while (DateTime.UtcNow - start < timeout)
+ {
+ if (condition())
+ return true;
+
+ await Task.Delay(20);
+ }
+
+ return condition();
+ }
}
}
diff --git a/tests/Neo.Wallet.Tests/Json/UT_WalletModel.cs b/tests/Neo.Wallet.Tests/Json/UT_WalletModel.cs
index 4ff7bfd..1c1435d 100644
--- a/tests/Neo.Wallet.Tests/Json/UT_WalletModel.cs
+++ b/tests/Neo.Wallet.Tests/Json/UT_WalletModel.cs
@@ -68,10 +68,10 @@ public void TestPropertyValues()
Assert.AreEqual(expectedTestWalletAccountModel.Lock, actualTestWalletAccountModel.Lock);
Assert.IsNotNull(actualTestWalletAccountModel.Key);
- CollectionAssert.AreEqual(expectedTestWalletAccountModel.Key!, actualTestWalletAccountModel.Key);
+ Assert.AreSequenceEqual(expectedTestWalletAccountModel.Key!, actualTestWalletAccountModel.Key);
Assert.IsNotNull(actualTestWalletAccountModel.Contract);
- CollectionAssert.AreEqual(expectedTestWalletAccountModel.Contract!.Script, actualTestWalletAccountModel.Contract.Script);
+ Assert.AreSequenceEqual(expectedTestWalletAccountModel.Contract!.Script, actualTestWalletAccountModel.Contract.Script);
Assert.IsNotNull(actualTestWalletAccountModel.Contract.Parameters);
Assert.HasCount(1, actualTestWalletAccountModel.Contract.Parameters);