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