commit 703213cb9f9a1073168073d8a6b3b7b2d618cf72 Author: VextaProject Date: Sun Sep 20 21:24:31 2026 +0000 Initial Vexta CPU Miner v0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6347ca6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# .NET build output +bin/ +obj/ + +# Publish / release working directories +publish-*/ +release-*/ + +# Release archives - uploaded separately to GitHub Releases +*.zip +*.tar.gz +*.sha256 + +# Local backups and development snapshots +*.before-* +*.working-* +*.failed-* +*.v0.1.0-working + +# Local third-party build/source tree +third_party/ + +# IDE / OS files +.vs/ +.vscode/ +.idea/ +*.user +*.suo +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd65798 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vexta Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..5629cbf --- /dev/null +++ b/Program.cs @@ -0,0 +1,967 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net.Sockets; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using NBitcoin; +using NBitcoin.DataEncoders; +using Vexta.CpuMiner; + +internal static class Program +{ + private static readonly object ConsoleLock = new(); + private static readonly BigInteger Diff1 = + BigInteger.Parse( + "00ffff0000000000000000000000000000000000000000000000000000", + NumberStyles.HexNumber); + + private const string Realm = "vexta-cpuminer"; + + private static readonly SemaphoreSlim WriterLock = new(1, 1); + private static readonly SemaphoreSlim JobLock = new(1, 1); + + private static readonly ConcurrentDictionary PendingSubmits = new(); + + private static StreamWriter? writer; + + private static string worker = ""; + private static string password = "x"; + private static string extraNonce1 = ""; + private static int extraNonce2Size; + private static int threadCount; + + private static double currentDifficulty; + + private static CancellationTokenSource? currentJobCts; + private static Task? currentMiningTask; + private static string? currentSeed; + + private static long nextSubmitId = 100; + private static long totalHashes; + private static long acceptedShares; + private static long rejectedShares; + + private static async Task Main(string[] args) + { + try + { + await RunAsync(args); + } + catch(OperationCanceledException) + { + } + catch(Exception ex) + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"FATAL: {ex.Message}"); + Environment.ExitCode = 1; + } + } + + private static async Task RunAsync(string[] args) + { + if(args.Length == 0 || Array.Exists(args, x => x == "-h" || x == "--help")) + { + PrintHelp(); + return; + } + + var poolUrl = GetArg(args, "-o") + ?? throw new ArgumentException("Missing required option: -o"); + + worker = GetArg(args, "-u") + ?? throw new ArgumentException("Missing required option: -u"); + + password = GetArg(args, "-p") ?? "x"; + + var threadText = GetArg(args, "-t"); + + threadCount = threadText != null + ? int.Parse(threadText, CultureInfo.InvariantCulture) + : Environment.ProcessorCount; + + if(threadCount < 1) + throw new ArgumentException("Thread count must be at least 1"); + + if(!poolUrl.StartsWith("stratum+tcp://", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Pool URL must start with stratum+tcp://"); + + var endpoint = poolUrl["stratum+tcp://".Length..]; + var colon = endpoint.LastIndexOf(':'); + + if(colon <= 0 || + !int.TryParse(endpoint[(colon + 1)..], out var port) || + port < 1 || + port > 65535) + { + throw new ArgumentException( + "Invalid Stratum URL. Expected stratum+tcp://HOST:PORT"); + } + + var host = endpoint[..colon]; + + using var shutdown = new CancellationTokenSource(); + + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + + if(!shutdown.IsCancellationRequested) + { + Console.WriteLine(); + Console.WriteLine("Stopping miner..."); + shutdown.Cancel(); + } + }; + + PrintBanner(); + Console.WriteLine($"Pool : {host}:{port}"); + Console.WriteLine($"Worker : {worker}"); + Console.WriteLine($"Threads : {threadCount}"); + Console.WriteLine(); + + var statsTask = StatsLoopAsync(shutdown.Token); + + while(!shutdown.IsCancellationRequested) + { + try + { + await RunSessionAsync(host, port, shutdown.Token); + } + catch(OperationCanceledException) when(shutdown.IsCancellationRequested) + { + break; + } + catch(Exception ex) + { + if(!shutdown.IsCancellationRequested) + { + Console.WriteLine(); + Console.WriteLine($"Disconnected: {ex.Message}"); + } + + await StopCurrentJobAsync(); + + if(shutdown.IsCancellationRequested) + break; + + Console.WriteLine("Reconnecting in 5 seconds..."); + + try + { + await Task.Delay(TimeSpan.FromSeconds(5), shutdown.Token); + } + catch(OperationCanceledException) + { + break; + } + } + } + + await StopCurrentJobAsync(); + + try + { + await statsTask; + } + catch(OperationCanceledException) + { + } + + Console.WriteLine(); + Console.WriteLine( + $"Stopped. Accepted: {Interlocked.Read(ref acceptedShares)}, " + + $"Rejected: {Interlocked.Read(ref rejectedShares)}"); + } + + private static async Task RunSessionAsync( + string host, + int port, + CancellationToken shutdown) + { + Console.WriteLine($"Connecting to {host}:{port} ..."); + + using var tcp = new TcpClient(); + + await tcp.ConnectAsync(host, port, shutdown); + + // ReadLineAsync() in .NET 6 cannot take a CancellationToken. + // Closing the socket on shutdown unblocks any pending Stratum read. + using var shutdownRegistration = shutdown.Register(() => + { + try + { + tcp.Dispose(); + } + catch + { + } + }); + + using var stream = tcp.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII); + + writer = new StreamWriter(stream, Encoding.ASCII) + { + AutoFlush = true, + NewLine = "\n" + }; + + extraNonce1 = ""; + extraNonce2Size = 0; + currentDifficulty = 0; + + await SendLineAsync( + "{\"id\":1,\"method\":\"mining.subscribe\",\"params\":[\"VextaCpuMiner/0.1\"]}", + shutdown); + + JsonElement? firstNotify = null; + var subscribed = false; + var authorized = false; + var authorizeSent = false; + + while(!shutdown.IsCancellationRequested && + (!subscribed || !authorized || firstNotify == null || currentDifficulty <= 0)) + { + var line = await reader.ReadLineAsync() + ?? throw new IOException("Stratum connection closed"); + + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if(root.TryGetProperty("id", out var id) && + id.ValueKind == JsonValueKind.Number) + { + var value = id.GetInt64(); + + if(value == 1) + { + var result = root.GetProperty("result"); + + extraNonce1 = result[1].GetString() + ?? throw new Exception("Pool returned invalid ExtraNonce1"); + + extraNonce2Size = result[2].GetInt32(); + subscribed = true; + + Console.WriteLine( + $"Subscribed. ExtraNonce1={extraNonce1}, " + + $"ExtraNonce2Size={extraNonce2Size}"); + + if(!authorizeSent) + { + await SendAuthorizeAsync(shutdown); + authorizeSent = true; + } + + continue; + } + + if(value == 2) + { + if(root.TryGetProperty("result", out var result) && + result.ValueKind == JsonValueKind.True) + { + authorized = true; + WriteColor("Authorized.", ConsoleColor.Green); + } + else + { + throw new Exception("Pool authorization failed"); + } + + continue; + } + } + + if(root.TryGetProperty("method", out var method) && + method.ValueKind == JsonValueKind.String) + { + var methodName = method.GetString(); + + if(methodName == "mining.block_found") + { + var parameters = root.GetProperty("params"); + + var height = parameters[0].GetInt64(); + var hash = parameters[1].GetString() ?? ""; + + WriteColor( + $"*** BLOCK FOUND! Height {height} ***", + ConsoleColor.Magenta); + + WriteColor( + $"Hash: {hash}", + ConsoleColor.Magenta); + + continue; + } + + if(methodName == "mining.set_difficulty") + { + currentDifficulty = root + .GetProperty("params")[0] + .GetDouble(); + + Console.WriteLine( + $"Difficulty set to {currentDifficulty.ToString("G", CultureInfo.InvariantCulture)}"); + + continue; + } + + if(methodName == "mining.notify") + { + firstNotify = root.GetProperty("params").Clone(); + continue; + } + } + } + + if(firstNotify == null) + throw new Exception("Pool did not provide an initial mining job"); + + await SwitchJobAsync(firstNotify.Value, shutdown); + + Console.WriteLine("Mining started."); + Console.WriteLine(); + + while(!shutdown.IsCancellationRequested) + { + var line = await reader.ReadLineAsync() + ?? throw new IOException("Stratum connection closed"); + + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if(root.TryGetProperty("method", out var method) && + method.ValueKind == JsonValueKind.String) + { + var methodName = method.GetString(); + + if(methodName == "mining.block_found") + { + var parameters = root.GetProperty("params"); + + var height = parameters[0].GetInt64(); + var hash = parameters[1].GetString() ?? ""; + + WriteColor( + $"*** BLOCK FOUND! Height {height} ***", + ConsoleColor.Magenta); + + WriteColor( + $"Hash: {hash}", + ConsoleColor.Magenta); + + continue; + } + + if(methodName == "mining.set_difficulty") + { + var diff = root + .GetProperty("params")[0] + .GetDouble(); + + if(diff > 0) + { + currentDifficulty = diff; + + Console.WriteLine( + $"Difficulty changed to {diff.ToString("G", CultureInfo.InvariantCulture)}"); + } + + continue; + } + + if(methodName == "mining.notify") + { + var job = root.GetProperty("params").Clone(); + + await SwitchJobAsync(job, shutdown); + continue; + } + + continue; + } + + if(root.TryGetProperty("id", out var id) && + id.ValueKind == JsonValueKind.Number) + { + var submitId = id.GetInt64(); + + if(PendingSubmits.TryRemove(submitId, out var submittedJob)) + { + var accepted = + root.TryGetProperty("result", out var result) && + result.ValueKind == JsonValueKind.True; + + if(accepted) + { + var count = Interlocked.Increment(ref acceptedShares); + WriteColor( + $"ACCEPTED share #{count} job={submittedJob}", + ConsoleColor.Green); + } + else + { + var count = Interlocked.Increment(ref rejectedShares); + + string errorText = ""; + + if(root.TryGetProperty("error", out var error) && + error.ValueKind != JsonValueKind.Null) + { + errorText = $" error={error}"; + } + + WriteColor( + $"REJECTED share #{count} job={submittedJob}{errorText}", + ConsoleColor.Red); + } + } + } + } + } + + private static async Task SendAuthorizeAsync(CancellationToken ct) + { + var json = JsonSerializer.Serialize(new + { + id = 2, + method = "mining.authorize", + @params = new object[] { worker, password } + }); + + await SendLineAsync(json, ct); + } + + private static async Task SendLineAsync( + string line, + CancellationToken ct) + { + var target = writer + ?? throw new IOException("Stratum writer is not connected"); + + await WriterLock.WaitAsync(ct); + + try + { + await target.WriteLineAsync(line); + await target.FlushAsync(); + } + finally + { + WriterLock.Release(); + } + } + + private static async Task SwitchJobAsync( + JsonElement job, + CancellationToken shutdown) + { + await JobLock.WaitAsync(shutdown); + + try + { + if(job.ValueKind != JsonValueKind.Array || job.GetArrayLength() < 10) + { + throw new Exception( + "RandomX Stratum job does not contain the 10th randomx_seed parameter"); + } + + var parsed = ParseJob(job); + + await StopCurrentJobAsync(); + + if(currentSeed != null && + !currentSeed.Equals(parsed.Seed, StringComparison.OrdinalIgnoreCase)) + { + VextaRandomX.DeleteSeed(Realm, currentSeed); + currentSeed = null; + } + + if(currentSeed == null) + { + WriteColor( + "Preparing RandomX dataset...", + ConsoleColor.Cyan); + + Console.WriteLine($"Seed: {parsed.Seed}"); + + VextaRandomX.CreateSeed( + Realm, + parsed.Seed, + vmCount: threadCount); + + currentSeed = parsed.Seed; + + WriteColor( + "RandomX dataset ready.", + ConsoleColor.Green); + } + + currentJobCts = + CancellationTokenSource.CreateLinkedTokenSource(shutdown); + + var token = currentJobCts.Token; + + WriteColor( + $"NEW JOB {parsed.JobId} nTime={parsed.NTimeHex} seed={parsed.Seed}", + ConsoleColor.Yellow); + + currentMiningTask = + MineJobAsync(parsed, token); + } + finally + { + JobLock.Release(); + } + } + + private static async Task StopCurrentJobAsync() + { + var cts = currentJobCts; + var task = currentMiningTask; + + currentJobCts = null; + currentMiningTask = null; + + if(cts != null) + { + try + { + cts.Cancel(); + } + catch + { + } + } + + if(task != null) + { + try + { + await task; + } + catch(OperationCanceledException) + { + } + } + + cts?.Dispose(); + } + + private static MiningJob ParseJob(JsonElement job) + { + var jobId = job[0].GetString() + ?? throw new Exception("Invalid job id"); + + var prevHashLE = job[1].GetString() + ?? throw new Exception("Invalid previous block hash"); + + var coinbase1 = job[2].GetString() + ?? throw new Exception("Invalid coinbase1"); + + var coinbase2 = job[3].GetString() + ?? throw new Exception("Invalid coinbase2"); + + var branches = job[4].Clone(); + + var versionHex = job[5].GetString() + ?? throw new Exception("Invalid version"); + + var bitsHex = job[6].GetString() + ?? throw new Exception("Invalid bits"); + + var nTimeHex = job[7].GetString() + ?? throw new Exception("Invalid nTime"); + + var seed = job[9].GetString() + ?? throw new Exception("Invalid RandomX seed"); + + var extraNonce2 = new string('0', extraNonce2Size * 2); + + var coinbaseHex = + coinbase1 + + extraNonce1 + + extraNonce2 + + coinbase2; + + var coinbase = Convert.FromHexString(coinbaseHex); + var coinbaseHash = Sha256D(coinbase); + var merkleRoot = BuildMerkleRoot(coinbaseHash, branches); + + var prevBytes = Convert.FromHexString(prevHashLE); + + // Miningcore sends previousBlockHash using ReverseByteOrder(): + // reverse the order of eight 4-byte words, not all 32 bytes. + var restoredPrevBytes = new byte[prevBytes.Length]; + + for(var i = 0; i < prevBytes.Length; i += 4) + { + Array.Copy( + prevBytes, + prevBytes.Length - 4 - i, + restoredPrevBytes, + i, + 4); + } + + var prevDisplay = + Convert.ToHexString(restoredPrevBytes).ToLowerInvariant(); + + var version = uint.Parse( + versionHex, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + + var nTime = uint.Parse( + nTimeHex, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture); + + return new MiningJob( + jobId, + prevDisplay, + merkleRoot, + version, + bitsHex, + nTime, + nTimeHex, + seed, + extraNonce2); + } + + private static async Task MineJobAsync( + MiningJob job, + CancellationToken ct) + { + var tasks = new Task[threadCount]; + + for(var workerIndex = 0; workerIndex < threadCount; workerIndex++) + { + var localIndex = workerIndex; + + tasks[workerIndex] = Task.Run( + async () => + { + for(ulong n = (ulong)localIndex; + n <= uint.MaxValue && !ct.IsCancellationRequested; + n += (ulong)threadCount) + { + var nonce = (uint)n; + +#pragma warning disable 618 + var header = new BlockHeader +#pragma warning restore 618 + { + Version = unchecked((int)job.Version), + HashPrevBlock = uint256.Parse(job.PrevDisplay), + HashMerkleRoot = new uint256(job.MerkleRoot), + BlockTime = DateTimeOffset.FromUnixTimeSeconds(job.NTime), + Bits = new Target( + Encoders.Hex.DecodeData(job.BitsHex)), + Nonce = nonce + }; + + var headerBytes = header.ToBytes(); + var rxHash = new byte[32]; + + VextaRandomX.CalculateHash( + Realm, + job.Seed, + headerBytes, + rxHash); + + Interlocked.Increment(ref totalHashes); + + var hashValue = new BigInteger( + rxHash, + isUnsigned: true, + isBigEndian: false); + + if(hashValue.IsZero) + continue; + + var shareDifficulty = + (double)Diff1 / + (double)hashValue; + + var required = + Volatile.Read(ref currentDifficulty) * 0.99d; + + if(required <= 0 || + shareDifficulty < required) + { + continue; + } + + var nonceHex = + nonce.ToString("x8", CultureInfo.InvariantCulture); + + Console.WriteLine( + $"FOUND share job={job.JobId} nonce={nonceHex} diff={shareDifficulty:E6}"); + + try + { + await SubmitShareAsync( + job, + nonceHex, + ct); + } + catch(OperationCanceledException) + { + break; + } + catch(Exception ex) + { + Console.WriteLine( + $"Submit failed: {ex.Message}"); + } + } + }, + ct); + } + + await Task.WhenAll(tasks); + } + + private static async Task SubmitShareAsync( + MiningJob job, + string nonceHex, + CancellationToken ct) + { + var submitId = + Interlocked.Increment(ref nextSubmitId); + + PendingSubmits[submitId] = job.JobId; + + var submit = JsonSerializer.Serialize(new + { + id = submitId, + method = "mining.submit", + @params = new object[] + { + worker, + job.JobId, + job.ExtraNonce2, + job.NTimeHex, + nonceHex + } + }); + + try + { + await SendLineAsync(submit, ct); + } + catch + { + PendingSubmits.TryRemove(submitId, out _); + throw; + } + } + + private static async Task StatsLoopAsync(CancellationToken ct) + { + var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); + var sw = Stopwatch.StartNew(); + + var lastHashes = Interlocked.Read(ref totalHashes); + var lastTime = sw.Elapsed; + var measuring = false; + + try + { + while(await timer.WaitForNextTickAsync(ct)) + { + var now = sw.Elapsed; + var hashes = Interlocked.Read(ref totalHashes); + + if(hashes == 0) + { + lastHashes = hashes; + lastTime = now; + continue; + } + + if(measuring && hashes == lastHashes) + { + lastTime = now; + measuring = false; + continue; + } + + if(!measuring) + { + lastHashes = hashes; + lastTime = now; + measuring = true; + continue; + } + + var deltaHashes = hashes - lastHashes; + var deltaTime = (now - lastTime).TotalSeconds; + + var rate = + deltaTime > 0 + ? deltaHashes / deltaTime + : 0; + + WriteColor( + $"HASHRATE {FormatHashrate(rate)} " + + $"A:{Interlocked.Read(ref acceptedShares)} " + + $"R:{Interlocked.Read(ref rejectedShares)}", + ConsoleColor.Cyan); + + lastHashes = hashes; + lastTime = now; + } + } + finally + { + timer.Dispose(); + } + } + + private static string FormatHashrate(double value) + { + if(value >= 1_000_000) + return $"{value / 1_000_000:F2} MH/s"; + + if(value >= 1_000) + return $"{value / 1_000:F2} kH/s"; + + return $"{value:F2} H/s"; + } + + private static byte[] Sha256D(byte[] data) + { + using var sha = SHA256.Create(); + + var first = sha.ComputeHash(data); + return sha.ComputeHash(first); + } + + private static byte[] BuildMerkleRoot( + byte[] coinbaseHash, + JsonElement branches) + { + var current = coinbaseHash; + + foreach(var branch in branches.EnumerateArray()) + { + var branchBytes = + Convert.FromHexString(branch.GetString()!); + + var combined = + new byte[current.Length + branchBytes.Length]; + + Buffer.BlockCopy( + current, 0, + combined, 0, + current.Length); + + Buffer.BlockCopy( + branchBytes, 0, + combined, current.Length, + branchBytes.Length); + + current = Sha256D(combined); + } + + return current; + } + + private static string? GetArg( + string[] args, + string name) + { + for(var i = 0; i < args.Length - 1; i++) + { + if(args[i].Equals( + name, + StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + + return null; + } + + private static void PrintBanner() + { + var oldColor = Console.ForegroundColor; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(@"* * ***** * * ***** *** +* * * * * * * * +* * **** * * ***** + * * * * * * * * + * ***** * * * * *"); + + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine(); + Console.WriteLine("Vexta CPU Miner 0.1.0"); + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("Copyright (c) 2026 Vexta Project"); + Console.WriteLine("Licensed under the MIT License"); + + Console.ForegroundColor = oldColor; + Console.WriteLine(); + } + + private static void WriteColor(string text, ConsoleColor color) + { + lock(ConsoleLock) + { + var oldColor = Console.ForegroundColor; + + try + { + Console.ForegroundColor = color; + Console.WriteLine(text); + } + finally + { + Console.ForegroundColor = oldColor; + } + } + } + + private static void PrintHelp() + { + PrintBanner(); + Console.WriteLine("Usage:"); + Console.WriteLine( + " Vexta.CpuMiner -o stratum+tcp://HOST:PORT -u ADDRESS.worker [-p x] [-t THREADS]"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -o Stratum pool URL"); + Console.WriteLine(" -u Vexta address / worker name"); + Console.WriteLine(" -p Password (default: x)"); + Console.WriteLine( + " -t CPU threads (default: logical CPU count)"); + Console.WriteLine(); + Console.WriteLine("Ctrl+C stops the miner cleanly."); + } + + private sealed record MiningJob( + string JobId, + string PrevDisplay, + byte[] MerkleRoot, + uint Version, + string BitsHex, + uint NTime, + string NTimeHex, + string Seed, + string ExtraNonce2); +} diff --git a/Vexta.CpuMiner.csproj b/Vexta.CpuMiner.csproj new file mode 100644 index 0000000..e3bcda4 --- /dev/null +++ b/Vexta.CpuMiner.csproj @@ -0,0 +1,11 @@ + + + Exe + net6.0 + true + + + + + + diff --git a/VextaRandomX.cs b/VextaRandomX.cs new file mode 100644 index 0000000..10ed411 --- /dev/null +++ b/VextaRandomX.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Vexta.CpuMiner; + +public static unsafe class VextaRandomX +{ + [Flags] + public enum RandomXFlags + { + Default = 0, + LargePages = 1, + HardAes = 2, + FullMem = 4, + Jit = 8, + Secure = 16, + Argon2Ssse3 = 32, + Argon2Avx2 = 64, + Argon2 = 96 + } + + [DllImport("librandomx", EntryPoint = "randomx_get_flags", CallingConvention = CallingConvention.Cdecl)] + private static extern RandomXFlags GetFlags(); + + [DllImport("librandomx", EntryPoint = "randomx_alloc_cache", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr AllocCache(RandomXFlags flags); + + [DllImport("librandomx", EntryPoint = "randomx_init_cache", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr InitCache(IntPtr cache, IntPtr key, int keySize); + + [DllImport("librandomx", EntryPoint = "randomx_release_cache", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr ReleaseCache(IntPtr cache); + + [DllImport("librandomx", EntryPoint = "randomx_alloc_dataset", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr AllocDataset(RandomXFlags flags); + + [DllImport("librandomx", EntryPoint = "randomx_dataset_item_count", CallingConvention = CallingConvention.Cdecl)] + private static extern ulong DatasetItemCount(); + + [DllImport("librandomx", EntryPoint = "randomx_init_dataset", CallingConvention = CallingConvention.Cdecl)] + private static extern void InitDataset( + IntPtr dataset, + IntPtr cache, + ulong startItem, + ulong itemCount); + + [DllImport("librandomx", EntryPoint = "randomx_release_dataset", CallingConvention = CallingConvention.Cdecl)] + private static extern void ReleaseDataset(IntPtr dataset); + + [DllImport("librandomx", EntryPoint = "randomx_create_vm", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateVm( + RandomXFlags flags, + IntPtr cache, + IntPtr dataset); + + [DllImport("librandomx", EntryPoint = "randomx_destroy_vm", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyVm(IntPtr machine); + + [DllImport("librandomx", EntryPoint = "randomx_calculate_hash", CallingConvention = CallingConvention.Cdecl)] + private static extern void CalculateHashNative( + IntPtr machine, + byte* input, + int inputSize, + byte* output); + + private sealed class GenContext : IDisposable + { + public int VmCount { get; init; } + + public IntPtr Cache { get; private set; } + + public IntPtr Dataset { get; private set; } + + public void Init(ReadOnlySpan key, RandomXFlags flags) + { + Cache = AllocCache(flags); + + if(Cache == IntPtr.Zero) + throw new InvalidOperationException("Unable to allocate RandomX cache."); + + fixed(byte* keyPtr = key) + { + InitCache(Cache, (IntPtr)keyPtr, key.Length); + } + + if((flags & RandomXFlags.FullMem) != 0) + { + Dataset = AllocDataset(flags); + + if(Dataset == IntPtr.Zero) + throw new InvalidOperationException("Unable to allocate RandomX dataset."); + + var itemCount = DatasetItemCount(); + InitDataset(Dataset, Cache, 0, itemCount); + + ReleaseCache(Cache); + Cache = IntPtr.Zero; + } + } + + public void Dispose() + { + if(Dataset != IntPtr.Zero) + { + ReleaseDataset(Dataset); + Dataset = IntPtr.Zero; + } + + if(Cache != IntPtr.Zero) + { + ReleaseCache(Cache); + Cache = IntPtr.Zero; + } + } + } + + private sealed class RxVm : IDisposable + { + private IntPtr vm; + + public void Init( + RandomXFlags flags, + IntPtr cache, + IntPtr dataset) + { + vm = CreateVm(flags, cache, dataset); + + if(vm == IntPtr.Zero) + throw new InvalidOperationException("Unable to create RandomX VM."); + } + + public void CalculateHash( + ReadOnlySpan data, + Span result) + { + fixed(byte* input = data) + fixed(byte* output = result) + { + CalculateHashNative(vm, input, data.Length, output); + } + } + + public void Dispose() + { + if(vm != IntPtr.Zero) + { + DestroyVm(vm); + vm = IntPtr.Zero; + } + } + } + + private sealed class SeedContext + { + public GenContext Context { get; init; } = null!; + + public BlockingCollection Vms { get; init; } = null!; + } + + private static readonly Dictionary> Realms = new(); + + public static void CreateSeed( + string realm, + string seedHex, + int vmCount = 1) + { + lock(Realms) + { + if(!Realms.TryGetValue(realm, out var seeds)) + { + seeds = new Dictionary(); + Realms[realm] = seeds; + } + + if(seeds.ContainsKey(seedHex)) + return; + + if(vmCount == -1) + vmCount = Environment.ProcessorCount; + + var flags = GetFlags() | RandomXFlags.FullMem; + + var context = new GenContext + { + VmCount = vmCount + }; + + context.Init(Convert.FromHexString(seedHex), flags); + + var vms = new BlockingCollection(); + + Parallel.For(0, vmCount, _ => + { + var vm = new RxVm(); + vm.Init(flags, context.Cache, context.Dataset); + vms.Add(vm); + }); + + seeds[seedHex] = new SeedContext + { + Context = context, + Vms = vms + }; + } + } + + public static void DeleteSeed(string realm, string seedHex) + { + SeedContext? seed; + + lock(Realms) + { + if(!Realms.TryGetValue(realm, out var seeds)) + return; + + if(!seeds.Remove(seedHex, out seed)) + return; + } + + for(var i = 0; i < seed.Context.VmCount; i++) + { + var vm = seed.Vms.Take(); + vm.Dispose(); + } + + seed.Context.Dispose(); + seed.Vms.Dispose(); + } + + public static void CalculateHash( + string realm, + string seedHex, + ReadOnlySpan data, + Span result) + { + if(result.Length < 32) + throw new ArgumentException( + "RandomX result buffer must be at least 32 bytes.", + nameof(result)); + + SeedContext? seed = null; + + lock(Realms) + { + if(Realms.TryGetValue(realm, out var seeds)) + seeds.TryGetValue(seedHex, out seed); + } + + if(seed is null) + { + result[..32].Clear(); + return; + } + + RxVm? vm = null; + + try + { + vm = seed.Vms.Take(); + vm.CalculateHash(data, result); + } + finally + { + if(vm is not null) + seed.Vms.Add(vm); + } + } +}