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