diff --git a/README.md b/README.md index 252ea540..2334db48 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ More usage examples: - [ ] NIP-10: Conventions for clients' use of `e` and `p` tags in text events - [ ] NIP-11: Relay Information Document - [ ] NIP-12: Generic Tag Queries -- [ ] NIP-13: Proof of Work -- [ ] NIP-14: Subject tag in text events +- [x] NIP-13: Proof of Work +- [x] NIP-14: Subject tag in text events - [x] NIP-15: End of Stored Events Notice - [x] NIP-19: bech32-encoded entities - [x] NIP-20: Command Results @@ -133,6 +133,151 @@ More usage examples: **Pull Requests are welcome!** +#### Proof of Work (NIP-13) + +Proof of Work in Nostr allows clients to demonstrate computational effort spent on creating an event. This can be used for spam reduction by requiring events to have a minimum difficulty level. + +##### Basic Usage + +```csharp +// Create an event +var ev = new NostrEvent +{ + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "This message includes proof of work to demonstrate NIP-13" +}; + +// Mine the event with difficulty 16 (leading zero bits) +var minedEvent = await ev.GeneratePow(16); + +// Once mined, sign it with your private key +var key = NostrPrivateKey.FromBech32("nsec1xxx"); +var signedMinedEvent = minedEvent.Sign(key); + +// Check the actual difficulty achieved +int difficulty = signedMinedEvent.GetDifficulty(); +Console.WriteLine($"Event ID: {signedMinedEvent.Id}"); +Console.WriteLine($"Difficulty achieved: {difficulty} bits"); + +// Send to relay +client.Send(new NostrEventRequest(signedMinedEvent)); +``` + +##### Validating Proof of Work + +When receiving an event, you can verify if it meets a minimum difficulty requirement: + +```csharp +// Check if an event has a valid PoW with a difficulty of at least 15 bits +int minDifficulty = 15; +bool isValid = receivedEvent.HasValidPow(minDifficulty); +if (isValid) +{ + Console.WriteLine($"Event has valid PoW with difficulty: {receivedEvent.GetDifficulty()} bits"); +} +else +{ + Console.WriteLine("Event does not have sufficient proof of work"); +} +``` + +##### Advanced: Using Delegate-based Mining for Progress Updates + +For longer mining operations, you may want to receive progress updates and have more control over the mining process. The library provides a delegate-based approach for this: + +```csharp +// Define delegate handlers for progress and completion +void OnProgress(string currentNonce, int difficulty, long attemptsCount) +{ + Console.WriteLine($"Mining in progress - Current nonce: {currentNonce}, " + + $"Current difficulty: {difficulty}, Attempts: {attemptsCount}"); +} + +void OnComplete(bool success, string nonce, int difficulty, long totalAttempts, long elapsedMs) +{ + if (success) + { + Console.WriteLine($"Mining successful! Found nonce: {nonce}"); + Console.WriteLine($"Difficulty achieved: {difficulty} bits"); + Console.WriteLine($"Total attempts: {totalAttempts:N0} in {elapsedMs}ms"); + Console.WriteLine($"Hash rate: {totalAttempts * 1000 / elapsedMs} hashes/second"); + } + else + { + Console.WriteLine($"Mining was cancelled or failed after {totalAttempts:N0} attempts"); + } +} + +// Create and configure a PoW calculator +var calculator = new PowCalculator(); +calculator.OnProgress += OnProgress; // Subscribe to progress updates +calculator.OnCompletion += OnComplete; // Subscribe to completion notification + +try +{ + // Start PoW calculation with target difficulty and specified nonce size (in bytes) + string eventId = myEvent.ComputeId(); // First compute the event ID + await calculator.StartCalculation(eventId, 20, 4); // Target 20 bits, 4-byte nonce + + // The mining happens on a background thread, your UI remains responsive + + // If you need to cancel the calculation: + // calculator.CancelCalculation(); +} +catch (Exception ex) +{ + Console.WriteLine($"Error in PoW calculation: {ex.Message}"); +} +finally +{ + // Important: Unsubscribe when done to prevent memory leaks + calculator.OnProgress -= OnProgress; + calculator.OnCompletion -= OnComplete; +} +``` + +##### Manual Mining with Low-level API + +For more control over the mining process, you can use the lower-level `EventMiner` class directly: + +```csharp +// Create a new event +var originalEvent = new NostrEvent +{ + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "Mining with low-level API" +}; + +// Set up cancellation (optional, for timeout) +using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + +try +{ + // Mine the event directly using the EventMiner + var minedEvent = await EventMiner.MineEventAsync(originalEvent, 16, cts.Token); + + Console.WriteLine($"Mining successful! Event ID: {minedEvent.Id}"); + Console.WriteLine($"Difficulty: {minedEvent.GetDifficulty()} bits"); + + // Find the nonce used + var nonceTag = minedEvent.Tags?.FindFirstTag("nonce"); + if (nonceTag != null) + { + Console.WriteLine($"Nonce: {nonceTag.AdditionalData[0]}"); + Console.WriteLine($"Target difficulty: {nonceTag.AdditionalData[1]}"); + } + + // Send the event to a relay + client.Send(new NostrEventRequest(minedEvent)); +} +catch (OperationCanceledException) +{ + Console.WriteLine("Mining was cancelled or timed out"); +} +``` + ### Reconnecting A built-in reconnection invokes after 1 minute (default) of not receiving any messages from the server. diff --git a/apps/nostr-debug/NostrDebug.Web/Components/NavMenu.razor b/apps/nostr-debug/NostrDebug.Web/Components/NavMenu.razor index db2176db..eaa87da5 100644 --- a/apps/nostr-debug/NostrDebug.Web/Components/NavMenu.razor +++ b/apps/nostr-debug/NostrDebug.Web/Components/NavMenu.razor @@ -44,6 +44,14 @@ +
  • + + + + Delegate PoW + + +
  • diff --git a/apps/nostr-debug/NostrDebug.Web/Pages/Delegate.razor b/apps/nostr-debug/NostrDebug.Web/Pages/Delegate.razor new file mode 100644 index 00000000..67406438 --- /dev/null +++ b/apps/nostr-debug/NostrDebug.Web/Pages/Delegate.razor @@ -0,0 +1,178 @@ +@page "/delegate-pow" +@using System.Diagnostics +@using NostrDebug.Web.Pow +@implements IDisposable + + + +
    +

    + This page demonstrates the use of delegates for reporting progress and completion of Proof of Work calculations. + The PowCalculator class uses delegates to report progress and completion of calculations. +

    + + + + Event ID + + + + + Target Difficulty + + + + Nonce Size + + + + + + Start PoW Calculation + + + + + Cancel + + + + @if (_isCalculating) + { +
    + + + Calculating PoW... Current nonce: @_currentNonce, Difficulty: @_currentDifficulty, Attempts: @_attempts + +
    + } + + @if (!string.IsNullOrEmpty(_resultNonce)) + { +
    + + + + PoW Successfully Generated + +
    Nonce: @_resultNonce
    +
    Difficulty: @_resultDifficulty
    +
    Total Attempts: @_totalAttempts.ToString("N0")
    +
    Time Taken: @_elapsedTime ms (@(_elapsedTime / 1000.0) seconds)
    +
    Speed: @((int)(_totalAttempts / (_elapsedTime / 1000.0))) hashes/second
    +
    +
    + } + + @if (_calculationFailed) + { +
    + + + PoW Calculation Failed or Cancelled + +
    + } +
    + +@code { + private string _eventId = ""; + private int _targetDifficulty = 16; + private int _nonceSize = 4; + + private bool _isCalculating; + private string _currentNonce = ""; + private int _currentDifficulty; + private long _attempts; + + private string _resultNonce = ""; + private int _resultDifficulty; + private long _totalAttempts; + private long _elapsedTime; + private bool _calculationFailed; + + // Create an instance of the PowCalculator + private PowCalculator _calculator = new PowCalculator(); + + protected override void OnInitialized() + { + // Subscribe to the events using delegate methods + _calculator.OnProgress += ProgressHandler; + _calculator.OnCompletion += CompletionHandler; + } + + public void Dispose() + { + // Important: Unsubscribe from events when component is disposed + _calculator.OnProgress -= ProgressHandler; + _calculator.OnCompletion -= CompletionHandler; + } + + private async Task StartCalculation() + { + if (string.IsNullOrWhiteSpace(_eventId)) + { + return; + } + + _isCalculating = true; + _resultNonce = ""; + _calculationFailed = false; + _currentNonce = ""; + _currentDifficulty = 0; + _attempts = 0; + + StateHasChanged(); + + try + { + await _calculator.StartCalculation(_eventId, _targetDifficulty, _nonceSize); + } + catch (Exception ex) + { + Console.WriteLine($"Error starting calculation: {ex.Message}"); + _isCalculating = false; + _calculationFailed = true; + StateHasChanged(); + } + } + + private void CancelCalculation() + { + _calculator.CancelCalculation(); + } + + // Handler for progress updates - this is called via the delegate + private void ProgressHandler(string currentNonce, int difficulty, long attemptsCount) + { + _currentNonce = currentNonce; + _currentDifficulty = difficulty; + _attempts = attemptsCount; + + // Ensure UI updates + InvokeAsync(StateHasChanged); + } + + // Handler for completion notification - this is called via the delegate + private void CompletionHandler(bool success, string nonce, int difficulty, long totalAttempts, long elapsedMs) + { + _isCalculating = false; + + if (success) + { + _resultNonce = nonce; + _resultDifficulty = difficulty; + _totalAttempts = totalAttempts; + _elapsedTime = elapsedMs; + } + else + { + _calculationFailed = true; + _totalAttempts = totalAttempts; + _elapsedTime = elapsedMs; + } + + // Ensure UI updates + InvokeAsync(StateHasChanged); + } +} diff --git a/apps/nostr-debug/NostrDebug.Web/Pages/Keys.razor b/apps/nostr-debug/NostrDebug.Web/Pages/Keys.razor index be9abe2f..74feb9a8 100644 --- a/apps/nostr-debug/NostrDebug.Web/Pages/Keys.razor +++ b/apps/nostr-debug/NostrDebug.Web/Pages/Keys.razor @@ -5,16 +5,16 @@ - +

    New Keys

    - + Generate - + - + @if (_generatedKeyPair != null) {
    @@ -35,9 +35,9 @@ }
    - +

    Derivation

    - +
    @@ -64,25 +64,30 @@

    Signing

    - +
    - + Public or private key - + Data - + Signature - + +
    +

    Proof of Work (NIP-13)

    + +
    +
    @@ -98,6 +103,10 @@ string? _dataForSignature; string? _signature; + string? _powEventId = ""; + string? _powNonce = ""; + int _powTargetDifficulty = 16; + private void OnGenerate() { _generatedKeyPair = NostrKeyPair.GenerateNew(); diff --git a/apps/nostr-debug/NostrDebug.Web/Pages/ProofOfWork.razor b/apps/nostr-debug/NostrDebug.Web/Pages/ProofOfWork.razor new file mode 100644 index 00000000..e2f95ed5 --- /dev/null +++ b/apps/nostr-debug/NostrDebug.Web/Pages/ProofOfWork.razor @@ -0,0 +1,30 @@ +@page "/pow" +@using Nostr.Client.Utils + + + + + +
    +

    + + Proof of Work in Nostr allows users to demonstrate computational effort spent on creating an event. + This can be used for spam reduction by requiring events to have a minimum difficulty level. +

    +

    + The difficulty is measured by the number of leading zero bits in the SHA-256 hash of the nonce concatenated with the event ID. +

    +
    + +
    + +
    + +
    + +@code +{ + private string _eventId = ""; + private string _nonce = ""; + private int _targetDifficulty = 20; +} diff --git a/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor b/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor new file mode 100644 index 00000000..92702faf --- /dev/null +++ b/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor @@ -0,0 +1,343 @@ +@using System.ComponentModel.DataAnnotations +@using System.Security.Cryptography +@using System.Threading.Tasks +@using System.Threading +@using System.Text + +
    + + + Event ID + + + + + Target Difficulty + + + @if (_isCalculating) + { +
    + + + Computing difficulty... + +
    + } + else if (_computedDifficulty.HasValue) + { +
    + Computed Difficulty: @_computedDifficulty +
    + + @if (_computedDifficulty.Value >= TargetDifficulty) + { +
    + + + Valid PoW: difficulty @_computedDifficulty.Value ≥ @TargetDifficulty + +
    + } + else + { +
    + + + Invalid PoW: difficulty @_computedDifficulty.Value < @TargetDifficulty + +
    + } + } + + + + + + Nonce (optional) + + + + + Nonce Size + + + + + + + + Generate PoW + + + + @if (_isGenerating) + { + + + Computing PoW... @_currentNonce + + + + + Cancel + + } + @if (!string.IsNullOrEmpty(_generatedNonce)) + { +
    + Generated Nonce: @_generatedNonce +
    + + + PoW Successfully Generated with difficulty @_generatedDifficulty + +
    +
    + } +
    + +@code { + private int? _computedDifficulty; + private bool _isCalculating; + private bool _isGenerating; + private string? _generatedNonce; + private int? _generatedDifficulty; + private CancellationTokenSource? _cancellationTokenSource; + private int _nonceSize = 4; + private string _currentNonce = ""; + + [Parameter] + public string? EventId { get; set; } + + [Parameter] + public EventCallback EventIdChangedCallback { get; set; } + + [Parameter] + public int TargetDifficulty { get; set; } = 16; + + [Parameter] + public EventCallback TargetDifficultyChangedCallback { get; set; } + + [Parameter] + public string? Nonce { get; set; } + + [Parameter] + public EventCallback NonceChangedEvent { get; set; } + + protected override async Task OnParametersSetAsync() + { + await CalculateDifficulty(); + } + + private async Task EventIdChanged(ChangeEventArgs args) + { + EventId = args.Value?.ToString(); + if (EventIdChangedCallback.HasDelegate) + { + await EventIdChangedCallback.InvokeAsync(EventId); + } + await CalculateDifficulty(); + } + + private async Task TargetDifficultyChanged(ChangeEventArgs args) + { + if (args.Value == null || string.IsNullOrWhiteSpace(args.Value.ToString())) + { + TargetDifficulty = 0; + } + else + { + TargetDifficulty = Convert.ToInt32(args.Value); + } + + if (TargetDifficultyChangedCallback.HasDelegate) + { + await TargetDifficultyChangedCallback.InvokeAsync(TargetDifficulty); + } + } + + private async Task NonceChanged(ChangeEventArgs args) + { + Nonce = args.Value?.ToString(); + if (NonceChangedEvent.HasDelegate) + { + await NonceChangedEvent.InvokeAsync(Nonce); + } + await CalculateDifficulty(); + } + + private async Task NonceSizeChanged(ChangeEventArgs args) + { + if (args.Value == null || string.IsNullOrWhiteSpace(args.Value.ToString())) + { + _nonceSize = 4; + } + else + { + _nonceSize = Convert.ToInt32(args.Value); + } + } + + private async Task CalculateDifficulty() + { + if (string.IsNullOrEmpty(EventId)) + { + _computedDifficulty = null; + return; + } + + _isCalculating = true; + StateHasChanged(); + + await Task.Run(() => + { + string idToCheck = EventId; + if (!string.IsNullOrEmpty(Nonce)) + { + idToCheck = Nonce + EventId; + } + + _computedDifficulty = CalculateLeadingZeroBits(idToCheck); + }); + + _isCalculating = false; + StateHasChanged(); + } + + private int CalculateLeadingZeroBits(string hex) + { + try + { + // Convert hex string to byte array + byte[] bytes = StringToByteArray(hex); + + // Calculate SHA-256 + using SHA256 sha256 = SHA256.Create(); + byte[] hash = sha256.ComputeHash(bytes); + + // Count leading zero bits + int leadingZeros = 0; + foreach (byte b in hash) + { + if (b == 0) + { + leadingZeros += 8; + } + else + { + int zeros = 0; + for (int i = 7; i >= 0; i--) + { + if ((b & (1 << i)) == 0) + { + zeros++; + } + else + { + break; + } + } + leadingZeros += zeros; + break; + } + } + + return leadingZeros; + } + catch (Exception ex) + { + Console.WriteLine($"Error calculating difficulty: {ex.Message}"); + return 0; + } + } + + private byte[] StringToByteArray(string hex) + { + int length = hex.Length; + byte[] bytes = new byte[length / 2]; + + for (int i = 0; i < length; i += 2) + { + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + } + + return bytes; + } + + private async Task GeneratePoW() + { + if (string.IsNullOrEmpty(EventId)) + { + return; + } + + _isGenerating = true; + _generatedNonce = null; + _generatedDifficulty = null; + _cancellationTokenSource = new CancellationTokenSource(); + var token = _cancellationTokenSource.Token; + + StateHasChanged(); + + try + { + await Task.Run(async () => + { + Random random = new Random(); + byte[] nonceBytes = new byte[_nonceSize]; + long attempts = 0; + + while (!token.IsCancellationRequested) + { + // Generate random nonce + random.NextBytes(nonceBytes); + string nonceHex = BitConverter.ToString(nonceBytes).Replace("-", "").ToLower(); + _currentNonce = nonceHex; + + if (attempts % 100 == 0) + { + await InvokeAsync(StateHasChanged); + } + + // Calculate difficulty with this nonce + int difficulty = CalculateLeadingZeroBits(nonceHex + EventId); + + attempts++; + + // If we found a nonce that meets or exceeds the target difficulty + if (difficulty >= TargetDifficulty) + { + _generatedNonce = nonceHex; + _generatedDifficulty = difficulty; + Nonce = nonceHex; + + if (NonceChangedEvent.HasDelegate) + { + await InvokeAsync(async () => await NonceChangedEvent.InvokeAsync(nonceHex)); + } + + break; + } + } + }, token); + } + catch (OperationCanceledException) + { + // Operation was canceled + } + finally + { + _isGenerating = false; + _cancellationTokenSource.Dispose(); + _cancellationTokenSource = null; + await CalculateDifficulty(); + StateHasChanged(); + } + } + + private void CancelPoW() + { + _cancellationTokenSource?.Cancel(); + } +} diff --git a/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor.css b/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor.css new file mode 100644 index 00000000..6598d0ac --- /dev/null +++ b/apps/nostr-debug/NostrDebug.Web/Pow/NostrPowValidator.razor.css @@ -0,0 +1,29 @@ +.pow-container { + width: 100%; +} + +.pow-valid { + padding: 10px; + border: dotted 2px var(--success); + border-radius: 5px; + color: var(--success); +} + +.pow-invalid { + padding: 10px; + border: dotted 2px var(--error); + border-radius: 5px; + color: var(--error); +} + +.pow-progress { + margin: 10px 0; +} + +.m-t-1 { + margin-top: 10px; +} + +.m-b-1 { + margin-bottom: 10px; +} diff --git a/apps/nostr-debug/NostrDebug.Web/Pow/PowDelegate.cs b/apps/nostr-debug/NostrDebug.Web/Pow/PowDelegate.cs new file mode 100644 index 00000000..305c2537 --- /dev/null +++ b/apps/nostr-debug/NostrDebug.Web/Pow/PowDelegate.cs @@ -0,0 +1,182 @@ +using System; +using System.Security.Cryptography; + +namespace NostrDebug.Web.Pow +{ + /// + /// Delegate definition for PoW calculation progress reporting + /// + /// The current nonce being tested + /// The difficulty of the current nonce + /// The number of attempts made so far + public delegate void PowProgressCallback(string currentNonce, int difficulty, long attemptsCount); + + /// + /// Delegate definition for PoW calculation completion + /// + /// Whether the PoW calculation was successful + /// The final nonce (if successful) + /// The difficulty achieved + /// The total number of attempts made + /// The time taken in milliseconds + public delegate void PowCompletionCallback(bool success, string nonce, int difficulty, long totalAttempts, long elapsedMs); + + public class PowCalculator + { + // Declare delegate instances as events + public event PowProgressCallback OnProgress; + public event PowCompletionCallback OnCompletion; + + private CancellationTokenSource _cancellationTokenSource; + private bool _isRunning; + + /// + /// Starts calculating a Proof of Work nonce for the given event ID + /// + /// The event ID to calculate PoW for + /// The target difficulty to achieve + /// The size of the nonce in bytes + /// A task representing the asynchronous operation + public async Task StartCalculation(string eventId, int targetDifficulty, int nonceSize = 4) + { + if (_isRunning) + { + throw new InvalidOperationException("A PoW calculation is already running"); + } + + if (string.IsNullOrEmpty(eventId)) + { + throw new ArgumentException("Event ID cannot be null or empty", nameof(eventId)); + } + + _isRunning = true; + _cancellationTokenSource = new CancellationTokenSource(); + var token = _cancellationTokenSource.Token; + + await Task.Run(async () => + { + var random = new Random(); + var nonceBytes = new byte[nonceSize]; + var attempts = 0L; + var startTime = DateTime.UtcNow; + var lastProgressUpdate = DateTime.UtcNow; + + try + { + while (!token.IsCancellationRequested) + { + // Generate random nonce + random.NextBytes(nonceBytes); + string nonceHex = BitConverter.ToString(nonceBytes).Replace("-", "").ToLower(); + + // Calculate difficulty with this nonce + int difficulty = CalculateLeadingZeroBits(nonceHex + eventId); + + attempts++; + + // Report progress every 100 attempts or 500ms + if (attempts % 100 == 0 || (DateTime.UtcNow - lastProgressUpdate).TotalMilliseconds > 500) + { + OnProgress?.Invoke(nonceHex, difficulty, attempts); + lastProgressUpdate = DateTime.UtcNow; + } + + // If we found a nonce that meets or exceeds the target difficulty + if (difficulty >= targetDifficulty) + { + var elapsedMs = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + OnCompletion?.Invoke(true, nonceHex, difficulty, attempts, elapsedMs); + return; + } + } + + // Calculation was cancelled + var elapsedCancelMs = (long)(DateTime.UtcNow - startTime).TotalMilliseconds; + OnCompletion?.Invoke(false, string.Empty, 0, attempts, elapsedCancelMs); + } + catch (Exception ex) + { + Console.WriteLine($"Error in PoW calculation: {ex.Message}"); + OnCompletion?.Invoke(false, string.Empty, 0, attempts, 0); + } + finally + { + _isRunning = false; + } + }, token); + } + + /// + /// Cancels the current PoW calculation + /// + public void CancelCalculation() + { + _cancellationTokenSource?.Cancel(); + } + + /// + /// Calculates the number of leading zero bits in the SHA-256 hash of the input + /// + /// The hex string to calculate the leading zero bits for + /// The number of leading zero bits + private int CalculateLeadingZeroBits(string hex) + { + try + { + // Convert hex string to byte array + byte[] bytes = StringToByteArray(hex); + + // Calculate SHA-256 + using SHA256 sha256 = SHA256.Create(); + byte[] hash = sha256.ComputeHash(bytes); + + // Count leading zero bits + int leadingZeros = 0; + foreach (byte b in hash) + { + if (b == 0) + { + leadingZeros += 8; + } + else + { + int zeros = 0; + for (int i = 7; i >= 0; i--) + { + if ((b & (1 << i)) == 0) + { + zeros++; + } + else + { + break; + } + } + leadingZeros += zeros; + break; + } + } + + return leadingZeros; + } + catch (Exception ex) + { + Console.WriteLine($"Error calculating difficulty: {ex.Message}"); + return 0; + } + } + + private byte[] StringToByteArray(string hex) + { + int length = hex.Length; + byte[] bytes = new byte[length / 2]; + + for (int i = 0; i < length; i += 2) + { + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + } + + return bytes; + } + } +} diff --git a/apps/nostr-debug/NostrDebug.Web/_Imports.razor b/apps/nostr-debug/NostrDebug.Web/_Imports.razor index ae901adc..fd1b4c85 100644 --- a/apps/nostr-debug/NostrDebug.Web/_Imports.razor +++ b/apps/nostr-debug/NostrDebug.Web/_Imports.razor @@ -20,4 +20,5 @@ @using NostrDebug.Web.Signing @using NostrDebug.Web.Relay @using NostrDebug.Web.External +@using NostrDebug.Web.Pow @using Microsoft.Fast.Components.FluentUI diff --git a/apps/nostr-debug/NostrDebug.Web/wwwroot/index.html b/apps/nostr-debug/NostrDebug.Web/wwwroot/index.html index d802c462..477c4319 100644 --- a/apps/nostr-debug/NostrDebug.Web/wwwroot/index.html +++ b/apps/nostr-debug/NostrDebug.Web/wwwroot/index.html @@ -3,7 +3,7 @@ - + Nostr Debug Tool diff --git a/src/Nostr.Client/Messages/NostrEvent.cs b/src/Nostr.Client/Messages/NostrEvent.cs index d03d4645..0e6ca1e7 100644 --- a/src/Nostr.Client/Messages/NostrEvent.cs +++ b/src/Nostr.Client/Messages/NostrEvent.cs @@ -238,5 +238,64 @@ private static string ReplaceValue(string json, string value, string replacement { return json.Replace(value, replacement); } + + /// + /// Gets the difficulty (number of leading zero bits) of the event's ID + /// + /// Number of leading zero bits in the event ID + public int GetDifficulty() + { + if (string.IsNullOrEmpty(Id)) + return 0; + + return NostrPow.Mining.DifficultyCalculator.CountLeadingZeroBits(Id); + } + + /// + /// Gets the target difficulty from the nonce tag if available + /// + /// Target difficulty value or null if not specified + public int? GetTargetDifficulty() + { + var nonceTag = Tags?.FindFirstTag("nonce"); + if (nonceTag == null || nonceTag.AdditionalData.Length < 2) + return null; + + if (int.TryParse(nonceTag.AdditionalData[1], out var targetDifficulty)) + return targetDifficulty; + + return null; + } + + /// + /// Check if the event has a valid proof of work at the specified minimum difficulty + /// + /// Minimum required difficulty + /// True if the event has enough proof of work + public bool HasValidPow(int minDifficulty) + { + // Get the actual difficulty + var difficulty = GetDifficulty(); + if (difficulty < minDifficulty) + return false; + + // Verify the committed target difficulty if present + var targetDifficulty = GetTargetDifficulty(); + if (targetDifficulty.HasValue && targetDifficulty.Value < minDifficulty) + return false; + + return true; + } + + /// + /// Generate proof of work for this event by mining for a nonce that creates an ID with the specified difficulty + /// + /// Target difficulty in bits + /// Cancellation token to stop mining + /// A new event with the proof of work + public async Task GeneratePow(int difficulty, CancellationToken cancellationToken = default) + { + return await NostrPow.Mining.EventMiner.MineEventAsync(this, difficulty, cancellationToken); + } } } diff --git a/src/Nostr.Client/NostrPow/Mining/DifficultyCalculator.cs b/src/Nostr.Client/NostrPow/Mining/DifficultyCalculator.cs new file mode 100644 index 00000000..4c6357a2 --- /dev/null +++ b/src/Nostr.Client/NostrPow/Mining/DifficultyCalculator.cs @@ -0,0 +1,81 @@ +using System; + +namespace Nostr.Client.NostrPow.Mining +{ + /// + /// Provides utility methods for calculating proof of work difficulty + /// + public static class DifficultyCalculator + { + /// + /// Count the number of leading zero bits in a hash (provided as hex string) + /// + /// Hex string representation of the hash + /// Number of leading zero bits + public static int CountLeadingZeroBits(string hex) + { + if (string.IsNullOrEmpty(hex)) + return 0; + + int count = 0; + + for (int i = 0; i < hex.Length; i++) + { + if (!TryParseHexDigit(hex[i], out int nibble)) + break; + + if (nibble == 0) + { + count += 4; // Each zero hex digit represents 4 zero bits + } + else + { + // Count leading zeros in this nibble + count += CountLeadingZeroBitsInNibble(nibble); + break; + } + } + + return count; + } + + /// + /// Count leading zero bits in a nibble (4-bit value) + /// + public static int CountLeadingZeroBitsInNibble(int nibble) + { + if (nibble >= 8) return 0; + if (nibble >= 4) return 1; + if (nibble >= 2) return 2; + if (nibble >= 1) return 3; + return 4; + } + + /// + /// Try to parse a hex character to its integer value + /// + private static bool TryParseHexDigit(char c, out int value) + { + if (c >= '0' && c <= '9') + { + value = c - '0'; + return true; + } + + if (c >= 'a' && c <= 'f') + { + value = c - 'a' + 10; + return true; + } + + if (c >= 'A' && c <= 'F') + { + value = c - 'A' + 10; + return true; + } + + value = 0; + return false; + } + } +} diff --git a/src/Nostr.Client/NostrPow/Mining/EventMiner.cs b/src/Nostr.Client/NostrPow/Mining/EventMiner.cs new file mode 100644 index 00000000..81bd2309 --- /dev/null +++ b/src/Nostr.Client/NostrPow/Mining/EventMiner.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Nostr.Client.Messages; + +namespace Nostr.Client.NostrPow.Mining +{ + /// + /// Provides methods for mining Nostr events (adding proof of work) + /// + public static class EventMiner + { + private static readonly Random Random = new Random(); + + /// + /// Mine an event to generate proof of work with the specified difficulty + /// + /// The event to mine + /// Target difficulty in bits + /// Cancellation token to stop mining + /// A new event with the proof of work + public static async Task MineEventAsync(NostrEvent originalEvent, int difficulty, CancellationToken cancellationToken = default) + { + // Create a cloned event for mining + var tags = originalEvent.Tags ?? new NostrEventTags(); + + // Find the existing nonce tag if any + NostrEventTag? nonceTag = tags.FindFirstTag("nonce"); + int nonceIndex = -1; + + if (nonceTag != null) + { + // Find the index of the nonce tag + for (int i = 0; i < tags.Count; i++) + { + if (tags[i].TagIdentifier == "nonce") + { + nonceIndex = i; + break; + } + } + } + + // Start with a random nonce + long nonce = Random.Next(0, int.MaxValue); + + // Create a new mutable copy of the tags + var newTags = new List(); + for (int i = 0; i < tags.Count; i++) + { + // Skip the existing nonce tag, we'll add a new one + if (i != nonceIndex) + { + newTags.Add(tags[i].DeepClone()); + } + } + + // We'll add the nonce tag at the end + NostrEventTag newNonceTag = new NostrEventTag("nonce", nonce.ToString(), difficulty.ToString()); + newTags.Add(newNonceTag); + + // Create the event to mine with initial tags + var eventToMine = originalEvent.DeepClone(null, null, originalEvent.Pubkey, new NostrEventTags(newTags)); + + // Update timestamp to current time, common practice in mining + var miningEvent = eventToMine.DeepClone(); + miningEvent = miningEvent.DeepClone(null, null, miningEvent.Pubkey, miningEvent.Tags); + // Set a current timestamp + miningEvent = new NostrEvent + { + Id = miningEvent.Id, + Pubkey = miningEvent.Pubkey, + CreatedAt = DateTime.UtcNow, // Update timestamp + Kind = miningEvent.Kind, + Tags = miningEvent.Tags, + Content = miningEvent.Content, + Sig = miningEvent.Sig + }; + + return await Task.Run(() => + { + while (!cancellationToken.IsCancellationRequested) + { + // Update the nonce in the tag + var updatedTags = new List(); + for (int i = 0; i < miningEvent.Tags!.Count; i++) + { + var tag = miningEvent.Tags[i]; + if (tag.TagIdentifier == "nonce") + { + // Update the nonce value + updatedTags.Add(new NostrEventTag("nonce", nonce.ToString(), difficulty.ToString())); + } + else + { + updatedTags.Add(tag.DeepClone()); + } + } + + // Create a new event with the updated nonce + var candidateEvent = miningEvent.DeepClone( + null, + miningEvent.Sig, + miningEvent.Pubkey, + new NostrEventTags(updatedTags)); + + // Compute the ID and check if it meets the difficulty requirement + string id = candidateEvent.ComputeId(); + int achievedDifficulty = DifficultyCalculator.CountLeadingZeroBits(id); + + if (achievedDifficulty >= difficulty) + { + // We found a valid nonce, return the mined event + return candidateEvent.DeepClone(id, candidateEvent.Sig); + } + + // Increment nonce and try again + nonce++; + } + + // Mining was canceled + throw new OperationCanceledException("Mining was canceled"); + }, cancellationToken); + } + } +} diff --git a/src/Nostr.Client/NostrPow/Mining/IMiner.cs b/src/Nostr.Client/NostrPow/Mining/IMiner.cs new file mode 100644 index 00000000..6d0bc438 --- /dev/null +++ b/src/Nostr.Client/NostrPow/Mining/IMiner.cs @@ -0,0 +1,21 @@ +using Nostr.Client.Messages; +using System.Threading; +using System.Threading.Tasks; + +namespace Nostr.Client.NostrPow.Mining +{ + /// + /// Interface for Nostr event miners + /// + public interface IMiner + { + /// + /// Mine an event to generate proof of work + /// + /// The event to mine + /// Target difficulty in bits + /// Cancellation token to stop mining + /// A new event with the proof of work + Task MineEventAsync(NostrEvent originalEvent, int difficulty, CancellationToken cancellationToken = default); + } +} diff --git a/src/Nostr.Client/Responses/NostrOkResponse.cs b/src/Nostr.Client/Responses/NostrOkResponse.cs index a7741424..6a71ec25 100644 --- a/src/Nostr.Client/Responses/NostrOkResponse.cs +++ b/src/Nostr.Client/Responses/NostrOkResponse.cs @@ -24,5 +24,11 @@ public class NostrOkResponse : NostrResponse /// [ArrayProperty(3)] public string? Message { get; init; } + + /// + /// Indicates if the response is successful. + /// + [ArrayProperty(2)] + public bool IsSuccess { get; init; } } } diff --git a/test/Nostr.Client.Tests/NostrPowTests.cs b/test/Nostr.Client.Tests/NostrPowTests.cs new file mode 100644 index 00000000..9050d131 --- /dev/null +++ b/test/Nostr.Client.Tests/NostrPowTests.cs @@ -0,0 +1,148 @@ +using Nostr.Client.Keys; +using Nostr.Client.Messages; +using Nostr.Client.NostrPow.Mining; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Nostr.Client.Tests +{ + public class NostrPowTests + { + [Theory] + [InlineData("000000d1b3d4ca36178939c4925a5ba9ade71593522b2011836c608742fbb905", 24)] + [InlineData("0000000f9490e4c266e0db3024b51d5cb10c0c0498824a20c35434c5c888d783", 28)] + [InlineData("00003576dad16c6181d07f1ea653933561b5f540144db9d95318486436a4378f", 18)] + [InlineData("01c3d4ca36178939c4925a5ba9ade71593522b2011836c608742fbb905", 7)] + [InlineData("f1c3d4ca36178939c4925a5ba9ade71593522b2011836c608742fbb905", 0)] + [InlineData("", 0)] + public void CountLeadingZeroBits_ShouldReturnCorrectCount(string hex, int expectedCount) + { + var result = DifficultyCalculator.CountLeadingZeroBits(hex); + Assert.Equal(expectedCount, result); + } + + [Theory] + [InlineData(0, 4)] + [InlineData(1, 3)] + [InlineData(2, 2)] + [InlineData(3, 2)] + [InlineData(4, 1)] + [InlineData(7, 1)] + [InlineData(8, 0)] + [InlineData(15, 0)] + public void CountLeadingZeroBitsInNibble_ShouldReturnCorrectCount(int nibble, int expectedBits) + { + var result = DifficultyCalculator.CountLeadingZeroBitsInNibble(nibble); + Assert.Equal(expectedBits, result); + } + + [Fact] + public async Task MineEvent_ShouldProduceValidDifficulty() + { + // Create a test event + var testEvent = new NostrEvent + { + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "Testing proof of work mining", + Pubkey = "a7319aeee29127d6bd1fb0562cf616e365a2b10d635a1cb9a86a23df4add73d7" + }; + + // Set a reasonable difficulty for the test (8 bits = 2 hex zeros) + int targetDifficulty = 8; + + // Mine the event + var minedEvent = await EventMiner.MineEventAsync(testEvent, targetDifficulty, CancellationToken.None); + + // Verify the result + Assert.NotNull(minedEvent); + + // Check that a nonce tag was added + var nonceTag = minedEvent.Tags?.FindFirstTag("nonce"); + Assert.NotNull(nonceTag); + Assert.Equal("nonce", nonceTag.TagIdentifier); + Assert.Equal(targetDifficulty.ToString(), nonceTag.AdditionalData[1]); + + // Verify that the difficulty requirement was met + int actualDifficulty = minedEvent.GetDifficulty(); + Assert.True(actualDifficulty >= targetDifficulty, + $"Generated event has difficulty {actualDifficulty} which is less than required {targetDifficulty}"); + + // Verify using the convenience method + Assert.True(minedEvent.HasValidPow(targetDifficulty)); + } + + [Fact] + public void HasValidPow_ShouldRespectTargetDifficulty() + { + // Create a test event with difficulty commitment less than actual difficulty + var testEvent = new NostrEvent + { + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "Testing proof of work validation", + Pubkey = "a7319aeee29127d6bd1fb0562cf616e365a2b10d635a1cb9a86a23df4add73d7", + Id = "0000fd1b3d4ca36178939c4925a5ba9ade71593522b2011836c608742fbb905", // 16-bit difficulty + Tags = new NostrEventTags( + new NostrEventTag("nonce", "12345", "10") // Only committed to 10-bit difficulty + ) + }; + + // Should pass validation when required difficulty is lower than committed + Assert.True(testEvent.HasValidPow(8)); + + // Should pass validation when required difficulty equals committed + Assert.True(testEvent.HasValidPow(10)); + + // Should fail validation when required difficulty is higher than committed + // (even though actual difficulty is higher) + Assert.False(testEvent.HasValidPow(12)); + } + + [Fact] + public async Task SignedMinedEvent_ShouldBeValid() + { + // Create a private key + var privateKey = NostrPrivateKey.GenerateNew(); + var publicKey = privateKey.DerivePublicKey(); + + // Create a test event + var testEvent = new NostrEvent + { + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "Testing signed proof of work", + Pubkey = publicKey.Hex + }; + + // Mine the event with difficulty 8 + var minedEvent = await EventMiner.MineEventAsync(testEvent, 8, CancellationToken.None); + + // Sign the mined event + var signedMinedEvent = minedEvent.Sign(privateKey); + + // Verify both PoW and signature + Assert.True(signedMinedEvent.HasValidPow(8)); + Assert.True(signedMinedEvent.IsSignatureValid()); + + // Create a tampered event with modified content + // We need to recalculate the ID to properly test signature validation failure + var tampered = new NostrEvent + { + // Don't copy the ID, it should be recalculated for the test + Pubkey = signedMinedEvent.Pubkey, + CreatedAt = signedMinedEvent.CreatedAt, + Kind = signedMinedEvent.Kind, + Tags = signedMinedEvent.Tags?.DeepClone(), + Content = "Tampered content", // Changed content + Sig = signedMinedEvent.Sig // Keep the same signature + }; + + // A tampered event with a different content but same signature should fail validation + Assert.False(tampered.IsSignatureValid()); + } + } +} + diff --git a/test_integration/Nostr.Client.Sample.Console/Program.cs b/test_integration/Nostr.Client.Sample.Console/Program.cs index f10ecee1..1f6f377d 100644 --- a/test_integration/Nostr.Client.Sample.Console/Program.cs +++ b/test_integration/Nostr.Client.Sample.Console/Program.cs @@ -41,6 +41,9 @@ new Uri("wss://nos.lol"), }; +// Example of mining a Nostr event with proof of work +await MineAndSendProofOfWorkExample(relays); + using var multiClient = new NostrMultiWebsocketClient(logFactory.CreateLogger()); var communicators = new List(); @@ -161,3 +164,93 @@ void SendDirectMessage(INostrClient client) client.Send(new NostrEventRequest(signed)); } + +static async Task MineAndSendProofOfWorkExample(Uri[] relays) +{ + try + { + Console.WriteLine("Mining a Nostr event with Proof of Work..."); + + // Generate a new private key + var privateKey = NostrPrivateKey.GenerateNew(); + var publicKey = privateKey.DerivePublicKey(); + + Console.WriteLine($"Using public key: {publicKey.Bech32}"); + + // Create an event to mine + var eventToMine = new NostrEvent + { + Kind = NostrKind.ShortTextNote, + CreatedAt = DateTime.UtcNow, + Content = "This is a test message with Proof of Work (NIP-13) from the Nostr.Client library.", + Pubkey = publicKey.Hex + }; + + // Set difficulty target (10 bits = about 2.5 hex zeros) + int targetDifficulty = 10; + Console.WriteLine($"Mining with target difficulty: {targetDifficulty} bits..."); + + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Mine the event + var minedEvent = await eventToMine.GeneratePow(targetDifficulty, cancellationTokenSource.Token); + + // Get the achieved difficulty + int achievedDifficulty = minedEvent.GetDifficulty(); + + Console.WriteLine($"Mining complete! Achieved difficulty: {achievedDifficulty} bits"); + Console.WriteLine($"Event ID: {minedEvent.Id}"); + + // Sign the mined event + var signedEvent = minedEvent.Sign(privateKey); + + // Connect to relay and send the event + using var communicator = new NostrWebsocketCommunicator(relays[0]); + using var client = new NostrWebsocketClient(communicator, null); + + TaskCompletionSource sentEvent = new TaskCompletionSource(); + + // Setup event handling + client.Streams.EventStream.Subscribe(response => + { + if (response?.Event?.Id == signedEvent.Id) + { + Console.WriteLine("Event was received back from relay!"); + sentEvent.TrySetResult(true); + } + }); + + client.Streams.OkStream.Subscribe(response => + { + if (response?.EventId == signedEvent.Id) + { + Console.WriteLine($"Event was accepted by relay: {response.IsSuccess}"); + if (!response.IsSuccess) + { + Console.WriteLine($"Reason: {response.Message}"); + } + } + }); + + // Connect to relay + Console.WriteLine($"Connecting to relay {relays[0]}..."); + await communicator.Start(); + + // Send the event + Console.WriteLine("Sending mined event to relay..."); + client.Send(new Nostr.Client.Requests.NostrEventRequest(signedEvent)); + + // Wait for confirmation (or timeout after 15 seconds) + await Task.WhenAny(sentEvent.Task, Task.Delay(15000)); + + Console.WriteLine("Proof of Work example completed."); + } + catch (OperationCanceledException) + { + Console.WriteLine("Mining was canceled (took too long)."); + } + catch (Exception ex) + { + Console.WriteLine($"Error in PoW example: {ex.Message}"); + } +}