Skip to content
Galaxoid-LabsPublic

About

Nostr Library .NET

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

NostrNet

NostrNet

A cross-platform .NET 10 Nostr client library. Pure managed code, single TFM (net10.0), AOT-compatible, minimal dependencies.

using NostrNet.Client;
using NostrNet.Keys;

using var key = PrivateKey.Generate();

await using var client = await NostrClient.Builder(key)
    .UseRelays("wss://relay.damus.io", "wss://nos.lol")
    .ConnectAsync();

await client.PostNoteAsync("Hello, Nostr!");

A real-app sketch — store, attach, observe

The recommended pattern for app-shaped usage: attach a local MemoryEventStore, fire-and-forget AttachAsync to feed it from relays, then bind your UI to live typed queries via store.ObserveAsync<T>(). One-way data flow, automatic dedup across relays, and SwiftData / Realm–style reactive UI updates.

using NostrNet.Client;
using NostrNet.Client.Storage;   // store.ObserveAsync<T> / QueryAsync<T> / GetAsync<T>
using NostrNet.Keys;
using NostrNet.Profiles;
using NostrNet.Relay;
using NostrNet.Relay.Storage;

using var key = PrivateKey.Generate();
var store = new MemoryEventStore();          // INostrEventStore — swap for SQLite later

await using var client = await NostrClient.Builder(key)
    .WithEventStore(store)
    .UseRelays("wss://relay.damus.io", "wss://nos.lol")
    .ConnectAsync();

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { cts.Cancel(); e.Cancel = true; };

// 1. Subscribe — events flow into the store. Fire-and-forget; no yield.
_ = client.AttachAsync(new[]
{
    new Filter { Kinds = new[] { 0, 1 } },              // profiles + text notes
    new Filter { Kinds = new[] { 30023 }, Limit = 50 },  // recent long-form articles
}, cts.Token);

// 2. Read typed values live — yields snapshot, then keeps yielding
//    as new matches arrive. UI binds here, not to the relay stream.
_ = Task.Run(async () =>
{
    await foreach (var profile in store.ObserveAsync<Profile>(cancellationToken: cts.Token))
        Console.WriteLine($"profile: {profile.Owner!.ToNpub()[..16]}… — {profile.Name}");
});

_ = Task.Run(async () =>
{
    await foreach (var article in store.ObserveAsync<NostrNet.Articles.Article>(cancellationToken: cts.Token))
        Console.WriteLine($"article: {article.Title} by {article.Author.ToNpub()[..16]}…");
});

// 3. Publish — same connection, same key.
await client.PostNoteAsync("hello from a real app");

// Idle until Ctrl+C; subscriptions and observers run in the background.
try { await Task.Delay(Timeout.Infinite, cts.Token); }
catch (OperationCanceledException) { }

The deeper "Local event store" section below covers the store's NIP-01 / NIP-09 / NIP-40 semantics, the full list of typed wrappers, and how to implement your own store backend.

Supported NIPs

NIP Feature
01 Core protocol — events, BIP-340 Schnorr signing, relay messaging (EVENT, REQ, EOSE, OK, NOTICE, CLOSED)
02 Contact / follow list (kind 3)
04 Legacy DM decode only (Nip04.Decrypt / Nip04.TryDecrypt) — no encrypt counterpart; use NIP-17 for new DMs
05 DNS-based identifier verification
09 Event deletion requests (kind 5) with e/a/k tags and Targets() rule
10 Thread / reply tagging (marker form + legacy positional fallback)
11 Relay information document
13 Proof of work (mining + validation)
17 Private direct messages
18 Reposts (kind 6 for kind-1 notes, kind 16 generic) + q-tag quote-reposts
19 Bech32 entities (npub, nsec, note, nprofile, nevent, naddr)
21 nostr: URI scheme
22 Comments (kind 1111) — threaded uppercase/lowercase root + parent tags
23 Long-form markdown articles & drafts (kinds 30023 / 30024)
25 Reactions (kind 7) — likes / dislikes / Unicode emoji / custom shortcode emoji
38 User statuses (kind 30315) — general activity + currently-playing music with NIP-40 expiration
39 External identity claims (i tags on kind-0) — GitHub / Twitter / Mastodon / Telegram + verification-message generator
42 Client-relay AUTH — auto-auth + auto-retry on auth-required by default
44 v2 encrypted payloads (ChaCha20 + HMAC-SHA256 + HKDF)
50 Full-text search (Filter.Search + NostrClient.SearchAsync, with NIP-11 capability check)
58 Badges — Definition (kind 30009), Award (kind 8), Profile Badges (kind 30008)
51 Lists & sets — public + NIP-44 self-encrypted private items
59 Gift wrap (used by NIP-17)
65 Relay list metadata (kind 10002 read/write relay advertisements)
98 HTTP Auth — sign requests with kind-27235 + Nip98AuthHandler for HttpClient
68 Picture-first feeds (kind 20, multi-image imeta attachments)
71 Video events (kinds 21/22 regular + 34235/34236 addressable)
92 Media attachments via imeta tag (shared parser/builder)
94 File metadata events (kind 1063)
B0 Web bookmarks (kind 39701; parameterized-replaceable by URL)
B7 Blossom user server list (kind 10063) + full BUD-00…12 client (HTTP, auth, mirror, media, list, delete, payment, reports, URI scheme) — separate NostrNet.Blossom package

Marmot (MLS over Nostr)

Marmot support is split into an envelope-only package and an OpenMLS-backed MLS provider. See src/NostrNet.Marmot/README.md for the full design and a complete walkthrough of 1:1 and group flows.

MIP Feature
00 KeyPackage publication (kind 30443)
01 Marmot Group Data extension (0xF2EE)
02 Welcome event (kind 444 wrapped in NIP-59 gift wrap)
03 Group event content encryption (kind 445, keyed off MLS exporter)

Group ops supported end-to-end through NostrNet.Marmot.Mls.Native (OpenMLS via FFI): 1:1 + N-party conversations, add peer, remove peer, key rotation (MLS self-update), application messages, NIP-25 reactions and NIP-09 deletions over the same MLS application channel, persistent state (SQLite). Conversation resume on startup via NostrMarmotClient.LoadExistingConversationsAsync (built on the IMarmotMlsProvider.ListGroupsAsync primitive). State-DB helpers DeleteGroupAsync / VacuumAsync / StateInfoAsync / WipeStateAsync cover the common "delete chat / reset / sign out" flows.

Tested interoperable with White Noise and the upstream mdk-core reference. Bidirectional 1:1 chat — KeyPackage publish, Welcome, add/remove, send/receive of kind-9 chat rumors — works against production Marmot clients.

Blossom (content-addressed media)

Blossom support lives in NostrNet.Blossom. See src/NostrNet.Blossom/README.md for the full API walkthrough. Highlights:

Layer API
High-level façade BlossomMediaClient.Builder(key).UseServers(...).Build() — upload (with mirror), download, list, delete, NIP-B7 publish, all in one
Multi-server resolver BlossomResolver walks BUD-03 / BUD-10 candidate order (server hints → author server lists → fallbacks)
Per-server HTTP client BlossomClient covers every BUD-01/02/04/05/06/09/12 endpoint plus typed 402 surface (BUD-07)
Auth tokens BlossomAuthToken.Create(verb, reason).ScopeToBlob(sha).BuildAndSign(key) → base64url-encoded Authorization: Nostr … (BUD-11)
URI scheme BlossomUri.Parse("blossom:…") and round-trip (BUD-10)

Tested against the official BIP-340, BIP-173, RFC 8439, and NIP-44 interop vectors — 700+ tests, zero warnings.

Install

Available on NuGet. Requires the .NET 10 SDK and <TargetFramework>net10.0</TargetFramework> in your csproj.

# Most apps need only this — it transitively pulls Core, Crypto, Relay.
dotnet add package NostrNet.Client --prerelease

# Add only what you need:
dotnet add package NostrNet.Blossom            --prerelease   # content-addressed media
dotnet add package NostrNet.Marmot             --prerelease   # MLS-over-Nostr envelopes
dotnet add package NostrNet.Marmot.Mls.Native  --prerelease   # OpenMLS engine (multi-RID native)

--prerelease is required until a stable v0.1.0 ships. Drop it once the API surface is frozen.

Marmot.Mls.Native ships native binaries for six RIDs (osx-x64, osx-arm64, linux-x64, linux-arm64, win-x64, win-arm64). NuGet picks the right one at restore. No Rust toolchain required to consume the package — only to build it from source.

Target framework compatibility

NostrNet targets net10.0. Your app's TFM must be net10.0 or higher to reference it (net10.0-windows10.0.19041.0 for WinUI 3 / Windows App SDK, plain net10.0 for console / ASP.NET, net10.0-android / net10.0-ios for MAUI, etc.). Check your app's .csproj:

<TargetFramework>net10.0</TargetFramework>

If you're stuck on .NET 8 / 9, NostrNet doesn't currently multi-target — you'd need to back-port (mostly C# 14 syntax → C# 12 equivalents and a few BCL polyfills).

Using with Godot

NostrNet works in Godot 4.x C# projects with no special setup. On desktop platforms (Windows / macOS / Linux) dotnet add package NostrNet.Client --prerelease in your Godot project's csproj is all you need.

Things to be aware of:

TFM. Godot 4.x defaults to net8.0. Bump your project to net10.0:

<TargetFramework>net10.0</TargetFramework>

This requires Godot 4.5+ — earlier versions pin you to older .NET runtimes.

Mobile (iOS / Android). NostrNet is AOT-safe so the library won't break Godot's mobile AOT pipeline. If a stripped build throws MissingMethodException, add NostrNet assemblies to your trim/AOT exclusion list.

Web (HTML5 / WASM). Browser .NET runtimes have intermittent support for HKDF and some System.Security.Cryptography APIs that NIP-44 / NIP-17 depend on. Test those specifically on the WASM export before committing — NIP-44 will either work fully or fail at the HKDF call.

Threading. Godot installs its own SynchronizationContext on the main thread, so await inside _Ready() / _Process() resumes on the main thread — you can touch nodes directly after the await:

using Godot;
using NostrNet.Client;
using NostrNet.Keys;

public partial class NostrNode : Node
{
    private NostrClient? _client;
    private PrivateKey? _key;

    public override async void _Ready()
    {
        _key = PrivateKey.Generate();
        _client = await NostrClient.Builder(_key)
            .UseRelays("wss://relay.damus.io")
            .ConnectAsync();

        await foreach (var received in _client.SubscribeNotesAsync(limit: 20))
        {
            // Back on the main thread — node access is safe.
            GD.Print($"[{received.Relay.Host}] {received.Event.Content}");
        }
    }

    public override void _ExitTree()
    {
        _key?.Dispose();
        _client?.DisposeAsync().AsTask().Wait();
    }
}

For work started on a background thread (e.g. NIP-13 mining), use CallDeferred to marshal scene access back to the main thread:

_ = Task.Run(() =>
{
    var mined = ProofOfWork.Mine(template, targetDifficulty: 20, ct);
    var signed = mined.Sign(_key);
    CallDeferred(MethodName.OnMined, signed.Id.ToHex());
});

private void OnMined(string idHex) => _label.Text = $"mined: {idHex}";

(Same CallDeferred pattern WPF/WinUI uses with Dispatcher.Invoke. See the "Threading model" section below for the general rules.)

Building from source (contributors)

Not needed to consume NostrNet via NuGet — only if you're cloning the repo to develop against it, run the tests, or build a custom version of the Native package.

NostrNet's pure-managed packages need only the .NET 10 SDK to build. NostrNet.Marmot.Mls.Native additionally needs the Rust toolchain and a C compiler (the in-tree nostrnet-marmot-native/ crate depends on rusqlite with the bundled feature, which compiles SQLite from C source via the cc crate).

All platforms:

  1. .NET 10 SDK — install from https://dotnet.microsoft.com/download/dotnet/10.0.
  2. Rust toolchain — install rustup:
    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    (On Windows, download rustup-init.exe from https://rustup.rs/ and run it.) rustup installs the stable toolchain and adds cargo + rustc to your PATH.

Platform-specific C compiler:

  • macOS — install the Xcode Command Line Tools (provides clang):

    xcode-select --install

    Usually already present if you've done any other development on the machine.

  • Linux (Debian / Ubuntu) — install build-essential:

    sudo apt-get update
    sudo apt-get install -y build-essential

    Other distros: install the equivalent gcc / clang + make package.

  • Windows — install Visual Studio 2022 (Community is free) or Visual Studio Build Tools 2022 with the "Desktop development with C++" workload. This gives you cl.exe (MSVC), the Windows SDK, and the linker. rustup on Windows defaults to the msvc toolchain, which depends on these.

After the prerequisites are in place:

git clone https://github.com/Galaxoid-Labs/NostrNet.git
cd NostrNet
dotnet build NostrNet.slnx
dotnet test NostrNet.slnx

The NostrNet.Marmot.Mls.Native.csproj invokes cargo build via an MSBuild target before the .NET compile; the first run downloads OpenMLS

  • dependencies (a few minutes) and compiles them. Subsequent builds use the cargo + .NET incremental caches.

Editors:

  • VS Code — install C# Dev Kit
  • Visual Studio 2022 — works out of the box once the .NET workload is installed.
  • JetBrains Rider — works out of the box.

Skipping the Native package: if you don't need MLS support and want to avoid the Rust toolchain, unload src/NostrNet.Marmot.Mls.Native/ from the solution (dotnet sln NostrNet.slnx remove ...) or work directly against the individual csprojs you need (Core, Crypto, Relay, Client, Marmot). All of those build with just .NET 10.

Testing the NuGet pack pipeline locally: see src/NostrNet.Marmot.Mls.Native/README.md — cargo build --release then copy the host's binary into src/NostrNet.Marmot.Mls.Native/prebuilt/<rid>/ and run dotnet pack -c Release. CI (.github/workflows/release.yml) handles cross-platform packing for tagged releases.

Project layout

Package Responsibility
NostrNet.Core Keys, events, canonical serialization, NIP-19 bech32, Profile, internal secp256k1 wrapper
NostrNet.Crypto ChaCha20, NIP-44 v2, NIP-17 DMs, NIP-59 gift wrap, NIP-51 lists
NostrNet.Relay WebSocket client, RelayPool, Filter, NIP-11 fetch, NIP-05 verify
NostrNet.Client High-level NostrClient façade
NostrNet.Blossom Blossom content-addressed media: HTTP client, NIP-B7 user servers, multi-server resolver, BlossomMediaClient façade
NostrNet.Marmot Marmot (MLS-over-Nostr) envelope: kind 30443 / 444 / 445, IMarmotMlsProvider, NIP-59 wrap of Welcomes
NostrNet.Marmot.Mls.Native OpenMLS-backed IMarmotMlsProvider via in-tree Rust FFI (nostrnet-marmot-native/). RFC-9420 compliant wire bytes; ships pre-built native binaries for six RIDs, requires the Rust toolchain only to build from source.

For most apps, reference only NostrNet.Client — it pulls in everything you need transitively. Blossom and Marmot live in separate packages so callers who don't want them aren't forced to take the dependency.


Quickstart

Connect with or without a key

The client can be constructed with a signing key (full feature set) or without one (read-only — subscribe, fetch relay info, publish pre-signed events). A key can also be attached later without rebuilding the connection.

// Full client: post / DM / subscribe to own DMs all available
await using var client = await NostrClient.Builder(myKey)
    .UseRelays("wss://relay.damus.io")
    .ConnectAsync();

// Read-only client: subscribe to public events while the user hasn't
// imported a key yet
await using var anon = await NostrClient.Builder()
    .UseRelays("wss://relay.damus.io")
    .ConnectAsync();

await foreach (var received in anon.SubscribeNotesAsync(limit: 50))
    Console.WriteLine(received.Event.Content);

// Later — user creates / imports a key. Attach it without reconnecting:
var newKey = PrivateKey.Generate();
anon.SetKey(newKey);
await anon.PostNoteAsync("now I'm signed in");

// Sign out (without disposing the client):
anon.ClearKey();

Helpers that need to sign or decrypt (PostNoteAsync, SendDirectMessageAsync, SubscribeDirectMessagesAsync) throw InvalidOperationException when called on a key-less client — guard with client.HasKey if you're unsure of state.

Vanity key generation

Mine a key whose npub or pubkey hex matches a chosen pattern — or whose pubkey carries proof-of-work leading-zero bits. Multi-threaded, with cancellation and progress reporting designed for UI apps.

using NostrNet.Keys;

// 1. PoW: pubkey with ≥ 20 leading zero bits
using var pow = await VanityKeyGenerator.MinePowAsync(leadingZeroBits: 20);

// 2. npub prefix (after "npub1")
using var alice = await VanityKeyGenerator.MineNpubPrefixAsync("alce");

// 3. Hex pubkey suffix
using var dead = await VanityKeyGenerator.MineHexSuffixAsync("dead");

Console.WriteLine(alice.PublicKey.ToNpub());   // npub1alce...
Console.WriteLine(dead.PublicKey.ToHex());     // ...dead

All five entry points (MinePowAsync, MineNpubPrefixAsync, MineNpubSuffixAsync, MineHexPrefixAsync, MineHexSuffixAsync) take the same optional parameters: threadCount (defaults to one per logical core), progress (an IProgress<VanityMiningProgress>), and cancellationToken.

Charset is validated up front. The bech32 alphabet excludes b, i, o, and 1, so MineNpubPrefixAsync("bob") throws ArgumentException immediately instead of looping forever. Use MineHexPrefixAsync("bob") if you want those characters in hex.

In a UI app

IProgress<T> is the standard plumbing for CPU-bound work that reports to the UI. Constructing a Progress<T> on the UI thread captures its SynchronizationContext — every .Report(...) call resumes there so you can touch UI directly:

private async void MineButton_Click(object sender, RoutedEventArgs e)
{
    _cts = new CancellationTokenSource();

    var progress = new Progress<VanityMiningProgress>(p =>
    {
        // Runs on the UI thread — safe to update widgets directly.
        statusLabel.Text = $"{p.Attempts:N0} attempts · {p.AttemptsPerSecond:N0}/sec";
        progressLabel.Text = p.Elapsed.ToString(@"mm\:ss");
    });

    try
    {
        using var key = await VanityKeyGenerator.MineNpubPrefixAsync(
            prefix: prefixInput.Text,
            progress: progress,
            cancellationToken: _cts.Token);

        npubLabel.Text = key.PublicKey.ToNpub();
        nsecLabel.Text = key.ToNsec();
    }
    catch (OperationCanceledException)
    {
        statusLabel.Text = "Cancelled.";
    }
}

private void CancelButton_Click(object sender, RoutedEventArgs e) => _cts?.Cancel();

The library throttles internal progress to ~one update per 500ms regardless of throughput, so the UI thread never gets flooded with marshaled callbacks.

Rough performance expectations

Approximate, on a modern multi-core laptop:

Pattern length Probability Expected time
1 char 1 in 32 < 1 second
3 chars 1 in 32,768 seconds
4 chars 1 in ~1M seconds–minute
5 chars 1 in ~33M ~1–2 min
6 chars 1 in ~1B ~30 min
7 chars 1 in ~33B hours
PoW 16 bits 1 in 65,536 < 1 second
PoW 24 bits 1 in ~16M seconds–minute
PoW 32 bits 1 in ~4B hours

Throughput scales near-linearly with core count. The dominant cost per attempt is the secp256k1 pubkey derivation — currently via the managed NBitcoin.Secp256k1 backend, which is portable but slower than native libsecp256k1. If you need 10× speed for very long patterns, a native backend (P/Invoke libsecp256k1) is a future option that would slot in behind the internal Secp256k1 wrapper without changing this API.

Generate or load a key

using NostrNet.Keys;

// Fresh CSPRNG-generated key
using var key = PrivateKey.Generate();

// Or load an existing one
using var key = PrivateKey.FromNsec("nsec1...");
using var key = PrivateKey.FromHex("1fb9778c...");

Console.WriteLine(key.PublicKey.ToNpub());   // npub1...
Console.WriteLine(key.PublicKey.ToHex());    // 32-byte hex

PrivateKey implements IDisposable and zeros its in-memory secret on Dispose. ToString() returns a redacted placeholder; it will never leak the secret in logs or stack traces. Use ToHex() / ToNsec() to obtain the secret explicitly when you need it.

Post a note

using NostrNet.Client;

await using var client = await NostrClient.Builder(key)
    .UseRelays("wss://relay.damus.io", "wss://nos.lol")
    .ConnectAsync();

var results = await client.PostNoteAsync("hello nostr");
foreach (var (uri, result) in results)
    Console.WriteLine($"{uri}: {(result.Accepted ? "OK" : "REJECTED")} {result.Message}");

Incoming events are verified automatically. RelayClient checks the event id (SHA-256 of canonical serialization) and the Schnorr signature on every event it receives from a relay; events that fail either check are silently dropped before they reach a subscriber. You don't need to call .Verify() on events yielded from SubscribeAsync. (Events parsed manually from JSON via NostrEvent.FromJson are not verified — call .Verify() yourself in that case.)

Subscribe to events

using NostrNet.Relay;

var filter = new Filter
{
    Authors = [key.PublicKey.ToHex()],
    Kinds = [1],
    Since = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds(),
    Limit = 50,
};

await foreach (var received in client.SubscribeAsync([filter]))
{
    Console.WriteLine($"[{received.Relay.Host}] {received.Event.CreatedAt}  {received.Event.Content}");
}

// Convenience for the common case
await foreach (var received in client.SubscribeNotesAsync(
    authors: [key.PublicKey], limit: 50))
{
    Console.WriteLine(received.Event.Content);
}

Each yielded item is a ReceivedEvent(NostrEvent Event, Uri Relay) — the relay that delivered this occurrence is exposed. The library intentionally does not store or dedup events; that's your call as the consumer. When multiple relays carry the same event, you'll see it once per relay, each with a different Relay. For a UI feed that should show each event once, dedup explicitly:

var seen = new HashSet<NostrNet.Events.EventId>();
await foreach (var received in client.SubscribeNotesAsync(limit: 100))
{
    if (!seen.Add(received.Event.Id)) continue;   // already shown
    feedListBox.Items.Add(received.Event.Content);
}

For relay-coverage analytics, don't dedup — track which relays carry which event ids.

Subscriptions are IAsyncEnumerable<ReceivedEvent> — they yield as events arrive and complete when all relays close the subscription or the CancellationToken fires.

Observing relay connections

Apps that talk to multiple relays usually want a per-relay status indicator and a "retrying…" badge. ObserveRelayConnectionsAsync is that primitive:

using NostrNet.Relay;

_ = Task.Run(async () =>
{
    await foreach (var s in client.ObserveRelayConnectionsAsync(cts.Token))
    {
        // s.Relay, s.State, s.Reason, s.Error, s.AttemptNumber
        statusByRelay[s.Relay] = s.State;
    }
});

Each RelayConnectionEvent carries:

  • Relay — the URI this event is about.
  • State — Connecting, Connected, or Disconnected.
  • Reason — for Disconnected: Disposed (terminal — the client/pool was disposed), ConnectFailed (handshake error, DNS, TCP refused), TransportError (WebSocket errored after being open), or ServerClosed (relay sent a clean close).
  • Error — the underlying exception for transport errors / connect failures, otherwise null.
  • AttemptNumber — 1 for the initial connect, incremented on each reconnect attempt. Useful for "retrying… (attempt N)" UI.

On subscribe, the stream yields a snapshot first — one event per relay currently known to the pool — so UI starts populated rather than empty. Multi-consumer: two UI surfaces can call ObserveRelayConnectionsAsync independently without stealing events from each other.

Auto-reconnect

On by default. When a relay drops with a non-Disposed reason, the pool retries with exponential backoff (1s, 2s, 4s, 8s, 16s, 30s, repeating at 30s). Each attempt emits Connecting → Connected on success or Connecting → Disconnected(ConnectFailed) on failure, so the observer sees the full retry timeline.

Auto-resubscribe

Also on by default. In-flight SubscribeAsync / AttachAsync calls transparently re-issue their REQ on the relay after it reconnects — your await foreach keeps yielding events from the new connection without surfacing a SubscriptionClosed for the transient drop. This makes "attach and forget" patterns survive flaky networks without any caller intervention.

One thing to know: filters are re-issued as-supplied. If your filter uses Since or Limit, you'll see overlap with events received before the drop. Pair with an event store (which auto-dedups by event id) for live feeds.

Opting out

Both behaviors are independently controllable on the builder:

await using var client = await NostrClient.Builder(key)
    .UseRelays("wss://relay.damus.io", "wss://nos.lol")
    .WithAutoReconnect(false)    // transport drops are terminal
    .WithAutoResubscribe(false)  // subscriptions end on disconnect
    .ConnectAsync();

WithAutoResubscribe(false) is useful if your app does fine-grained subscription lifecycle management itself; you still get reconnect for the status indicator. WithAutoReconnect(false) implies no resubscribe (there's no reconnect to resume over).

Per-call opt-out isn't needed: one-shot fetches that break out of the await foreach or cancel their CancellationToken don't trigger resume, because the pump cancels with the caller.

NIP-17 direct messages

var bob = PublicKey.FromNpub("npub1...");

// Send — publishes two gift wraps: one to bob, one to me. Returns per-relay
// outcomes for both. The recipient wrap is the load-bearing one; the
// self-wrap is what lets the sender's other devices reconstruct sent-
// message history.
var results = await client.SendDirectMessageAsync(bob, "hey bob");
foreach (var (uri, r) in results.ToRecipient)
    Console.WriteLine($"delivery  {uri}: {(r.Accepted ? "OK" : r.Message)}");
foreach (var (uri, r) in results.ToSelf)
    Console.WriteLine($"self-copy {uri}: {(r.Accepted ? "OK" : r.Message)}");

// Receive — gift wraps unwrap automatically. `dm.Kind` distinguishes chat
// (14), file (15), and reactions (7); `dm.RumorId` is what you reference
// in replies and reactions. `dm.Relay` tells you which relay carried this
// delivery.
await foreach (var dm in client.SubscribeDirectMessagesAsync())
{
    bool mine = dm.Sender.Equals(key.PublicKey);
    switch (dm.Kind)
    {
        case Nip17.RumorKind:                                          // kind 14 chat
            Console.WriteLine($"{(mine ? "[sent]" : $"[{dm.Sender.ToNpub()[..16]}…]")} {dm.Plaintext}");
            break;
        case Nip17.ReactionRumorKind:                                  // kind 7 reaction
            string targetId = dm.Tags.FirstValue("e") ?? "?";
            Console.WriteLine($"{dm.Sender.ToNpub()[..16]}… reacted {dm.Plaintext} to {targetId[..16]}…");
            break;
    }
}

Replying to a DM (NIP-10 markers, inside the wrap)

// I want to reply to a message bob sent me earlier.
var parentRumorId = previousDm.RumorId;   // from the UnwrappedDirectMessage

await client.SendDirectMessageAsync(
    recipient: bob,
    content: "yes!",
    replyTo: parentRumorId);   // adds ["e", id, "", "reply"] inside the rumor

For deeper threads, pass both replyTo (the immediate parent) and replyRoot (the thread's first message). Top-level replies omit replyRoot. The e tag points at the inner rumor id, not the kind-1059 gift wrap — only DM participants know that id, so threading stays as private as the messages.

Reacting to a DM (NIP-25 wrapped, never in the clear)

A clear kind-7 reaction would leak the conversation's existence (everyone sees "alice reacted to "). So reactions to DMs go through the same NIP-59 wrap pipeline:

await client.SendDirectMessageReactionAsync(
    targetRumorId: receivedDm.RumorId,
    targetAuthor: receivedDm.Sender,
    reaction: "👍");        // or "+" / "-" / a :shortcode:

The receiver sees the reaction arrive on the same SubscribeDirectMessagesAsync stream, with dm.Kind == Nip17.ReactionRumorKind. Switch on it to render a reaction badge on the referenced message instead of as a new chat row.

Deleting a DM (NIP-09 wrapped, never in the clear)

A clear kind-5 referencing a DM's inner rumor id would leak the conversation's existence the same way a clear reaction would. NIP-09 deletions for DM-family events use the same dual-wrap pipeline:

await client.SendDirectMessageDeletionAsync(
    targetRumorId: msg.RumorId,        // INNER rumor id, not outer wrap id
    targetAuthor: msg.Sender,           // wrap recipient (the conversation peer)
    targetKind: msg.Kind,               // 14 / 15 / 7
    reason: "typo");                    // optional NIP-09 reason

The receiver sees dm.Kind == Nip17.DeletionRumorKind (= 5), with an e tag pointing at the deleted rumor id and a k tag declaring the target's kind. Apps apply the deletion to their local view:

case Nip17.DeletionRumorKind:
    string? targetId = dm.Tags.FirstValue("e");
    string? targetKind = dm.Tags.FirstValue("k");
    // NIP-09 is advisory: only honor when dm.Sender == author of the
    // targeted event. The library can't enforce that here — apps look
    // up the targeted rumor in their local store and compare authors.
    if (targetId is not null && AuthorOf(targetId).Equals(dm.Sender))
        Apply(deletedRumorId: targetId, deletedKind: targetKind);
    break;

SendDirectMessageDeletionAsync doesn't need separate own-send echo plumbing — NIP-59 wraps are symmetric, so the sender's own SubscribeDirectMessagesAsync sees the deletion via the standard ToSelf-wrap echo (same path used by every other DM-family event since preview6).

Important: the e tag MUST reference the inner rumor id, not the outer kind-1059 wrap id. Outer wrap ids differ per recipient (ToRecipient ≠ ToSelf, and peers see their own distinct outer ids); only the inner rumor id is shared and identifies the same logical message on both sides.

Low-level: arbitrary rumor kinds

For file messages (kind 15) or app-specific rumor kinds — typing indicators, edits, read receipts — drop down to SendWrappedDmAsync:

await client.SendWrappedDmAsync(
    recipient: bob,
    kind: Nip17.FileRumorKind,
    content: "https://blossom.example/abc...def.jpg",
    tags: new IReadOnlyList<string>[]
    {
        new[] { "p", bob.ToHex() },
        new[] { "file-type", "image/jpeg" },
    });

SubscribeDirectMessagesAsync surfaces DM-family kinds only (chat / file / reaction). Stray non-DM-family wraps — e.g. Marmot kind-444 Welcomes that share the same outer kind 1059 — are silently filtered out at unwrap time so they don't pollute the DM stream.

Under the hood: Nip17.CreateDirectMessage / CreateReaction / WrapRumor all build a rumor → seal (signed by sender) → two kind-1059 gift wraps (recipient-addressed and self-addressed, same inner rumor). Recipients verify the seal's signature and the rumor's pubkey before the plaintext is surfaced, so a malicious outer wrap can't spoof dm.Sender.

Legacy NIP-04 DMs (decode only)

For apps migrating users from clients that predate the mid-2024 deprecation, Nip04.TryDecrypt reads kind-4 events. There is no encrypt counterpart — the scheme has no MAC and contributes nothing to the key derivation; use NIP-17 for new DMs.

using NostrNet.Crypto;

var filter = new Filter
{
    Kinds = new[] { Nip04.Kind },
    TagFilters = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
    {
        ["p"] = new[] { key.PublicKey.ToHex() },
    },
};

await foreach (var received in client.SubscribeAsync(new[] { filter }))
{
    if (Nip04.TryDecrypt(received.Event, key, out string? text, out PublicKey? peer))
        Console.WriteLine($"{peer.ToNpub()[..16]}…: {text}");
}

TryDecrypt resolves the peer automatically — sender's pubkey when you're the recipient, p-tag value when reading your own outbound — and is fail-closed. Non-kind-4 events, wrong key, malformed payloads all return false without throwing, so you can iterate a mixed feed without catch blocks.

Nip04.Decrypt(content, myKey, peerPublicKey) is the lower-level content-only variant; it throws FormatException / CryptographicException on bad input.


Building events manually

using NostrNet.Events;

var unsigned = new UnsignedEvent
{
    PubKey = key.PublicKey,
    CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
    Kind = 1,
    Tags = new IReadOnlyList<string>[]
    {
        new[] { "t", "nostr" },
        new[] { "client", "my-app" },
    },
    Content = "manually constructed",
};

NostrEvent signed = unsigned.Sign(key);
Console.WriteLine(signed.Id.ToHex());

// Verify a received event
if (signed.Verify())
    Console.WriteLine("signature OK");

// Wire JSON
string json = signed.ToJson();
var parsed = NostrEvent.FromJson(json);

Working with tags

Build tags with the Tag factory and query them with extensions on the event's tag list:

using NostrNet.Events;

// Building
var note = new UnsignedEvent
{
    PubKey = key.PublicKey,
    CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
    Kind = 1,
    Tags = new[]
    {
        Tag.P(recipient),                          // ["p", "<hex>"]
        Tag.E(parentId, "wss://relay.example.com", "reply"),  // NIP-10 reply marker
        Tag.T("nostr"),
        Tag.A(30023, articleAuthor, "my-slug"),    // addressable coordinate
    },
    Content = "...",
}.Sign(key);

// Querying
foreach (var p in ev.Tags.Pubkeys())              // every "p" tag as PublicKey
    Console.WriteLine(p.ToNpub());

foreach (var id in ev.Tags.EventIds())            // every "e" tag as EventId
    Console.WriteLine(id.ToHex());

string? articleSlug = ev.Tags.Identifier();        // the "d" tag's value
string? title = ev.Tags.FirstValue("title");
IEnumerable<string> hashtags = ev.Tags.Hashtags();
IEnumerable<string> mentioned = ev.Tags.AllValues("p");
bool hasReply = ev.Tags.Has("e");

// Drop down to raw rows when you need the third/fourth column (relay, marker):
foreach (var eTag in ev.Tags.Named("e"))
{
    var id = eTag[1];
    var relay = eTag.Count > 2 ? eTag[2] : null;
    var marker = eTag.Count > 3 ? eTag[3] : null;
}

The Tag.* factories never produce a tag with the wrong shape; the query extensions silently skip malformed rows so you never need defensive length-checking on the happy path.


NIP-19 bech32 entities

using NostrNet.Nip19;
using NostrNet.Events;
using NostrNet.Keys;

// Simple identifiers — direct on the typed value
var npub = key.PublicKey.ToNpub();
var note = signed.Id.ToNote();
var pub = PublicKey.FromNpub("npub1...");
var id = EventId.FromNote("note1...");

// TLV entities
var nprofile = new NprofileEntity
{
    PubKey = pub,
    Relays = new[] { "wss://relay.example.com" },
}.Encode();

var naddr = new NaddrEntity
{
    PubKey = pub,
    Kind = 30023,            // long-form article
    Identifier = "my-slug",
    Relays = new[] { "wss://relay.example.com" },
}.Encode();

// Parse anything (npub/note/nprofile/nevent/naddr)
var entity = Nip19.Parse("nevent1qqs...");
switch (entity)
{
    case NpubEntity n:     Console.WriteLine(n.PubKey.ToHex()); break;
    case NeventEntity e:   Console.WriteLine($"{e.Id} from {e.Relays.Count} relays"); break;
    case NaddrEntity a:    Console.WriteLine($"kind {a.Kind} d={a.Identifier}"); break;
}

// nostr: URI scheme
var uri = Nip21.ToUri(entity);                   // "nostr:nevent1qqs..."
var parsed = Nip21.Parse("nostr:npub1...");

nsec is deliberately NOT decoded by Nip19.Parse — callers must use PrivateKey.FromNsec explicitly so secret lifetime stays visible.


NIP-22 comments

Kind 1111 comments for threading on anything except kind:1 notes (use NIP-10 reply markers for those). Comments can scope to:

  • a specific event (e.g. a long-form article)
  • an addressable / parameterized-replaceable event (kind 30000+)
  • an external resource per NIP-73 (URL, hashtag, geohash, …)

The thread structure uses paired tag sets: uppercase (E/A/I + K + P) identifies the original target; lowercase (e/a/i + k + p) identifies the direct parent. For top-level comments they reference the same target.

using NostrNet.Comments;

// Top-level comment on someone's article
var top = Comment.ReplyTo(articleEvent)
    .WithContent("nice post!")
    .Sign(myKey);

// Nested reply — the builder inherits the root scope from the parent comment
var nested = Comment.ReplyTo(top)
    .WithContent("agreed")
    .Mention(otherPubkey)        // adds an extra "p" tag
    .Quote(someEventId)          // adds a NIP-21 "q" citation
    .Sign(myKey);

// Comment on an addressable (kind 30023 article) without holding the event
var byCoord = Comment.Create()
    .OnAddressable(kind: 30023, author: articleAuthorPub, identifier: "my-slug")
    .WithContent("found this via search")
    .Sign(myKey);

// Comment on an external URL (NIP-73)
var external = Comment.Create()
    .OnExternal("https://example.com/article", kind: "url")
    .WithContent("commenting on a blog post")
    .Sign(myKey);

// Reading
var parsed = Comment.FromEvent(receivedComment);
Console.WriteLine(parsed.Content);
Console.WriteLine(parsed.IsTopLevel ? "(top)" : $"(reply to {parsed.Parent})");

switch (parsed.Root)
{
    case EventCommentTarget e:
        Console.WriteLine($"thread root: event {e.Id}");
        break;
    case AddressableCommentTarget a:
        Console.WriteLine($"thread root: {a.ToCoordinate()}");
        break;
    case ExternalCommentTarget x:
        Console.WriteLine($"thread root: external {x.Identifier} ({x.Kind})");
        break;
}

Important: ReplyTo(kind:1 note) throws — NIP-22 explicitly defers to NIP-10 for note threading. Comment.TryFromEvent(...) is the non-throwing variant.


NIP-23 long-form articles

Markdown articles (kind 30023) and drafts (kind 30024). Both are parameterized-replaceable, keyed by a stable d-tag identifier (slug) — republishing with the same slug replaces the previous version.

using NostrNet.Articles;

// Build & publish
var ev = Article.Create("my-first-article", File.ReadAllText("post.md"))
    .WithTitle("My First Article")
    .WithSummary("An introduction to my new blog")
    .WithImage("https://example.com/cover.png")
    .WithPublishedAt(DateTimeOffset.UtcNow)
    .WithHashtags("intro", "nostr")
    .Sign(authorKey);

await client.PublishAsync(ev);

// As a draft (kind 30024) — same shape, different kind
var draft = Article.Create("my-first-article", workInProgress)
    .AsDraft()
    .Sign(authorKey);

// Read a received article event
var article = Article.FromEvent(receivedEvent);
Console.WriteLine($"{article.Title} by {article.Author.ToNpub()}");
Console.WriteLine(article.Markdown);

if (article.PublishedAt is DateTimeOffset pub)
    Console.WriteLine($"originally published {pub}");
else
    Console.WriteLine($"created {article.CreatedAt}");

// Share via a nostr:naddr1… URI
var naddr = article.ToNaddr(relays: new[] { "wss://relay.example.com" });
Console.WriteLine($"link: nostr:{naddr.Encode()}");

Article.TryFromEvent(ev, out var article) is the non-throwing variant for events that may or may not be NIP-23. Articles missing the required d tag are rejected.


NIP-B0 web bookmarks

Editable per-URL web bookmarks (kind 39701). The bookmark is keyed by its URL with the scheme stripped, so the same page over http:// and https:// collapses to a single addressable bookmark.

using NostrNet.Bookmarks;

// Build & publish
var ev = WebBookmark.Create("https://alice.blog/marvelous-post")
    .WithTitle("Alice's marvelous post")
    .WithDescription("a great insight into nostr lists")
    .WithHashtags("nostr", "long-form")
    .WithPublishedAt(DateTimeOffset.UtcNow)
    .Sign(key);

await client.PublishAsync(ev);

// Parse a received bookmark
var bm = WebBookmark.FromEvent(receivedEvent);
Console.WriteLine($"{bm.Title}  ({bm.ToUrl()})");
Console.WriteLine(bm.Description);
foreach (var tag in bm.Hashtags) Console.Write($"#{tag} ");

// Share as a nostr:naddr1... URI
var naddr = bm.ToNaddr(relays: new[] { "wss://relay.example.com" });
Console.WriteLine($"link: nostr:{naddr.Encode()}");

Create strips https://, http://, or leading // from the URL so revisions of the same bookmark land at the same d-tag value. bookmark.ToUrl() adds the scheme back (defaults to https, or pass "http" for HTTP-only sources).


NIP-51 lists

Build mute lists, bookmarks, pinned notes, follow sets, etc. — with both public items (in tags) and private items (NIP-44 self-encrypted in the event's content).

using NostrNet.Lists;

// Mute list (replaceable, one per author)
var muteEvent = NostrList.Create(Nip51Kinds.MuteList)
    .AddPubkey(spammer)                       // public — anyone can see
    .AddHashtag("crypto-scam")                // public
    .AddPrivatePubkey(secretBlock)            // encrypted in content
    .AddPrivateWord("personal-trigger-word")  // encrypted in content
    .Sign(key);

await client.PublishAsync(muteEvent);

// Parameterized set (multiple per author, distinguished by identifier)
var friends = NostrList.Create(Nip51Kinds.FollowSets, identifier: "close-friends")
    .WithTitle("Close Friends")
    .WithDescription("people I actually talk to")
    .WithImage("https://example.com/friends.png")
    .AddPubkey(alicePub)
    .AddPubkey(bobPub)
    .Sign(key);

// Reading
var list = NostrList.FromEvent(receivedEvent);          // public items only
var fullList = NostrList.FromEvent(receivedEvent, key); // public + decrypted private

foreach (var muted in fullList.Pubkeys)  Console.WriteLine(muted.ToNpub());
foreach (var tag in fullList.Hashtags)   Console.WriteLine($"#{tag}");
foreach (var word in fullList.Words)     Console.WriteLine($"muted word: {word}");

if (list.HasEncryptedContent && !fullList.PrivateItems.Any())
    Console.WriteLine("(legacy NIP-04 encrypted content — not yet supported)");

Nip51Kinds exposes constants for every documented kind (MuteList, PinnedNotes, Bookmarks, Communities, Interests, DmRelays, GoodWikiAuthors, FollowSets, RelaySets, BookmarkSets, ArticleCurationSets, KindMuteSets, EmojiSets, StarterPacks, …). Parameterized-set kinds (≥ 30000) require an identifier; Nip51Kinds.IsParameterizedSet(kind) checks the range.

Encryption uses NIP-44 self-encryption (modern, what current clients write). Lists encrypted by older NIP-04 clients leave PrivateItems empty; public items remain readable. NIP-04 backward-decoding is on the roadmap.


NIP-65 relay list metadata

A user advertises their preferred read/write relays via a single replaceable kind-10002 event. Other clients fetch this to know where to publish events that should reach them, and where to look for events they authored.

using NostrNet.RelayList;

// Build and publish
var ev = RelayListMetadata.Create()
    .AddRelay("wss://relay.damus.io")          // both read and write
    .AddReadRelay("wss://relay.nostr.band")    // read-only
    .AddWriteRelay("wss://nos.lol")            // write-only
    .Sign(key);

await client.PublishAsync(ev);

// Parse a received event
var list = RelayListMetadata.FromEvent(receivedEvent);
Console.WriteLine($"{list.Owner.ToNpub()} writes to: {string.Join(", ", list.WriteRelays)}");
Console.WriteLine($"{list.Owner.ToNpub()} reads from: {string.Join(", ", list.ReadRelays)}");

// Each entry carries the original URL + usage marker
foreach (var entry in list.Relays)
    Console.WriteLine($"{entry.Url} ({entry.Usage})");

RelayListMetadata.TryFromEvent(ev, out var list) is the non-throwing variant for events that may or may not be NIP-65.


NIP-42 relay AUTH

Some relays require NIP-42 authentication before they'll serve subscriptions or accept publishes. Auto-auth is on by default — when NostrClient is constructed with a key, every AUTH challenge is answered in the background, and any publish or subscription rejected with auth-required is transparently retried once AUTH succeeds. You don't need to write any retry code.

await using var client = await NostrClient.Builder(key)
    .UseRelays("wss://auth-required-relay.example.com")
    .ConnectAsync();

// Just publish. If the relay rejects with auth-required, the library
// waits for AUTH and resends. You only see the final result.
var results = await client.PostNoteAsync("hello");

// Same for subscriptions — if a relay closes the sub with auth-required,
// the library re-subscribes after AUTH. The consumer never sees the gap.
await foreach (var received in client.SubscribeNotesAsync(limit: 50))
    Console.WriteLine(received.Event.Content);

Auto-auth follows the key. If you call client.SetKey(...) later (the user signed in after browsing anonymously), auto-auth activates with the new key. client.ClearKey() disables it. You can also toggle at runtime: client.AutoAuth = false;.

Opt out if you want explicit control — e.g., to prompt the user before AUTHing, log every AUTH attempt, or skip AUTH on specific relays:

await using var client = await NostrClient.Builder(key)
    .UseRelays(...)
    .WithAutoAuth(false)
    .ConnectAsync();

// Manual flow — surface the rejection, AUTH explicitly, retry yourself
var results = await client.PostNoteAsync("hello");
if (results.Values.Any(r => !r.Accepted && r.Message.StartsWith("auth-required")))
{
    var authResults = await client.AuthenticateAllAsync();
    foreach (var (uri, r) in authResults)
        Console.WriteLine($"{uri}: AUTH {(r.Accepted ? "OK" : r.Message)}");

    results = await client.PostNoteAsync("hello");
}

How auto-retry works

  • The receive loop captures each ["AUTH", "<challenge>"] into per-relay state (RelayClient.LatestAuthChallenge).
  • When auto-auth is on and a key is set, RelayClient fires a background Task.Run that signs the kind-22242 event and sends ["AUTH", <event>].
  • Publishes: if PublishAsync gets a rejection whose message starts with auth-required, it awaits the in-flight AUTH (or triggers one if a challenge has just arrived) and resends the event once. Only the result of the second attempt is surfaced.
  • Subscriptions: each per-relay pump task in RelayPool watches for CLOSED auth-required. When it sees one (with auto-auth on), it waits for AUTH then transparently re-issues the REQ — the consumer's await foreach keeps yielding events without breaking.
  • Retry is capped at one attempt per operation so an unresolvable AUTH (key not on the relay's allow-list, expired payment, etc.) doesn't loop forever. The second rejection surfaces to the caller normally.

Single-relay control

Drop down to RelayClient (via the IRelayClient returned by RelayPool or constructed manually) when you need per-relay visibility:

string? challenge = relayClient.LatestAuthChallenge;
PublishResult r = await relayClient.AuthenticateAsync(myKey);
await relayClient.WaitForAuthAsync();   // block until current auto-auth finishes

For remote-signer flows (where signing happens elsewhere), use NostrNet.Auth.Nip42.BuildAuthEvent(key, relayUri, challenge) directly — returns the signed kind-22242 event ready to wrap in ["AUTH", ...].


NIP-05 verification

Given a kind-0 metadata event, verify the user's claimed identifier:

using NostrNet.Profiles;
using NostrNet.Relay;

// Parse kind-0 content into a typed Profile
var profile = Profile.FromEvent(kind0Event);
Console.WriteLine($"{profile.Name} ({profile.Nip05}) — {profile.About}");
Console.WriteLine($"picture: {profile.Picture}");
Console.WriteLine($"lightning: {profile.Lud16}");

// Verify the nip05 field
var r = await Nip05.VerifyAsync(profile);
if (r.IsVerified)
    Console.WriteLine($"✓ {r.Identifier} verified, suggested relays: {string.Join(", ", r.Relays)}");
else
    Console.WriteLine($"✗ {r.FailureReason}");

Other entry points:

// Verify directly without parsing a Profile
await Nip05.VerifyAsync(kind0Event);

// Verify a known pubkey ↔ identifier mapping
await Nip05.VerifyAsync(pubkey, "bob@example.com");

// Just fetch the document
Nip05Document doc = await Nip05.FetchAsync("bob@example.com");

The shared HttpClient has auto-redirect disabled per the NIP-05 "fetchers MUST ignore HTTP redirects" rule. Pass your own HttpClient for custom timeouts or proxies.


NIP-11 relay info

using NostrNet.Relay;

var info = await RelayInformation.FetchAsync(new Uri("wss://relay.damus.io"));

Console.WriteLine($"{info.Name} ({info.Software} {info.Version})");
Console.WriteLine($"limits: max_message={info.Limitation?.MaxMessageLength}, " +
                  $"max_subs={info.Limitation?.MaxSubscriptions}");

if (info.SupportsNip(44))
    Console.WriteLine("modern DMs OK");

if (info.Limitation?.AuthRequired == true)
    Console.WriteLine("relay requires NIP-42 AUTH");

RelayInformation is a strongly-typed record covering every documented NIP-11 field including limits and fees. Use RelayInformation.Parse(string) if you already have the JSON in hand.


NIP-13 proof of work

using NostrNet.Events;

var template = new UnsignedEvent
{
    PubKey = key.PublicKey,
    CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
    Kind = 1,
    Tags = Array.Empty<IReadOnlyList<string>>(),
    Content = "mine me",
};

// Mine until the id has 20 leading zero bits. Pass a CancellationToken
// to enforce a time budget.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var mined = ProofOfWork.Mine(template, targetDifficulty: 20, cts.Token);
var signed = mined.Sign(key);

Console.WriteLine($"id={signed.Id} difficulty={ProofOfWork.Difficulty(signed)}");

// Validation
bool ok = ProofOfWork.MeetsCommittedDifficulty(signed);

MeetsCommittedDifficulty returns true for events with no nonce tag (no PoW is claimed). For a minimum-difficulty policy regardless of what the event claims, compare ProofOfWork.Difficulty(ev) directly.


Local event store

By default NostrClient is stateless — each event from each relay flows through SubscribeAsync and back out to your code. You handle storage and dedup. Most apps eventually want the opposite: a single source of truth for "what events do I have" that the UI binds to, plus dedup across relays for free. INostrEventStore is that abstraction; MemoryEventStore is the built-in implementation.

using NostrNet.Client;
using NostrNet.Keys;
using NostrNet.Relay;
using NostrNet.Relay.Storage;

using var key = PrivateKey.Generate();
var store = new MemoryEventStore(capacity: 10_000);

await using var client = await NostrClient.Builder(key)
    .WithEventStore(store)                    // ← attach the store
    .UseRelays("wss://relay.damus.io", "wss://nos.lol")
    .ConnectAsync();

// 1. Tell relays what to send you. Events flow into the store; you
//    do NOT iterate this call — it's fire-and-forget.
using var cts = new CancellationTokenSource();
_ = client.AttachAsync(new[]
{
    new Filter { Kinds = new[] { 1 }, Authors = new[] { key.PublicKey.ToHex() } },
}, cts.Token);

// 2. Read from the store. ObserveAsync yields the current snapshot
//    first (oldest-first), then keeps yielding as new matching
//    events arrive — like SwiftData @Query / Realm live results.
await foreach (var ev in store.ObserveAsync(
    new Filter { Kinds = new[] { 1 } }, cts.Token))
{
    feedItems.Add(ev);    // UI auto-updates
}

// Or take a one-shot snapshot — completes after enumeration.
await foreach (var ev in store.QueryAsync(new Filter { Kinds = new[] { 0 } }))
    knownProfiles.Add(Profile.FromEvent(ev));

// Direct lookup by id.
NostrEvent? specific = await store.GetAsync(someEventId);

What the store handles for you

The interface contract — every implementation must honor these:

Concern Behavior
Dedup Same event id seen twice → StoreResult.Duplicate. Multi-relay delivery collapses to one stored copy.
NIP-01 replaceable (kinds 0, 3, 10000–19999) Keyed by (kind, pubkey). Newer created_at replaces older (StoreResult.Replaced); older returns Outdated.
NIP-01 parameterized-replaceable (30000–39999) Keyed by (kind, pubkey, d-tag). Same upsert semantics; different d-tags coexist. Missing d defaults to empty string per NIP-33.
NIP-09 deletion Kind-5 from the original author tombstones referenced event ids permanently. a-tag deletions evict the matching addressable but do NOT block newer republishes at the same address (per NIP-09).
NIP-40 expiration Events with an expiration tag in the past are rejected at store time and skipped at read time.
NIP-01 ephemeral (20000–29999) Fanned out to live ObserveAsync subscribers but not persisted — typing indicators and presence pings can't crowd out durable events.

When to use which method

Method Returns Use for
client.SubscribeAsync(filter) IAsyncEnumerable<ReceivedEvent> — raw relay stream (auto-deduped when a store is attached) One-off reads, lightweight scripts, when you don't have / want a store
client.AttachAsync(filter) Task — fire-and-forget; events flow into the store App-style usage: subscribe once, read from the store
store.QueryAsync(filter) IAsyncEnumerable<NostrEvent> — snapshot, completes after enumeration "Give me the current list" — newest-first, honors Filter.Limit
store.ObserveAsync(filter) IAsyncEnumerable<NostrEvent> — live; emits snapshot then keeps yielding UI data binding — gets initial values + live updates until cancelled
store.GetAsync(id) ValueTask<NostrEvent?> Direct lookup by event id

The data flow with a store attached is one-way:

relays  →  AttachAsync  →  store  →  Observe / Query  →  UI
                              ↑
                       single source of truth

Typed access — store.ObserveAsync<Profile>()

Raw events are fine when you want them. For everything else, the store returns typed values via a single generic surface — no need to call Profile.FromEvent(ev) in every loop:

using NostrNet.Client.Storage;     // generic ObserveAsync<T> / QueryAsync<T> / GetAsync<T>

// Default Kinds — uses Profile.Kinds = [0] automatically
await foreach (var profile in store.ObserveAsync<Profile>(ct: ct))
    cache[profile.Owner!] = profile;

// Caller-supplied filter narrows further (Article.Kinds = [30023, 30024])
await foreach (var article in store.ObserveAsync<Article>(
    new Filter { Kinds = new[] { 30023 } }, ct))   // published only, skip drafts
    feed.Add(article);

// Single typed lookup
Profile? alice = await store.GetAsync<Profile>(eventId);

// Snapshot only
await foreach (var bookmark in store.QueryAsync<WebBookmark>())
    Console.WriteLine(bookmark.ToUrl());

How it works: every typed wrapper implements INostrTypedEvent<TSelf> (static Kinds property + static TryFromEvent — both C# 11 static abstract interface members). The generic extension applies T.Kinds as the default filter, runs the underlying query, then projects each match through T.TryFromEvent, silently skipping events that fail to parse (malformed JSON, missing required tags, etc.).

Fully AOT-safe — each <T> instantiation resolves at compile time, no reflection, no type registry.

The following Core wrappers are typed-store ready out of the box:

Type Kinds NIP
Profile 0 01
ContactList 3 02
DeletionRequest 5 09
RepostEvent 6, 16 18
Reaction 7 25
BadgeAward 8 58
PictureEvent 20 68
VideoEvent 21, 22, 34235, 34236 71
FileMetadata 1063 94
Comment 1111 22
RelayListMetadata 10002 65
BlossomServerList 10063 B7
Article 30023, 30024 23
ProfileBadges 30008 58
BadgeDefinition 30009 58
UserStatus 30315 38
KeyPackageEvent 30443 (Marmot MIP-00)
WebBookmark 39701 B0

Your own types work too — implement INostrTypedEvent<TSelf> (one property, one method) and the generic extensions light up automatically.

Custom storage backends

You can implement INostrEventStore from scratch, but you'd be on the hook for re-deriving the entire NIP-01 / NIP-09 / NIP-40 semantics layer (dedup, replaceable / addressable upsert, deletion tombstones, expiration filtering, ephemeral fan-out, observer registry, snapshot+live merge for ObserveAsync). Don't.

Instead, derive from EventStoreBase and implement seven raw-persistence primitives:

public sealed class SqliteEventStore : EventStoreBase
{
    protected override bool TryAddRaw(NostrEvent ev)               { /* INSERT, return false on PK conflict */ }
    protected override bool TryRemoveRaw(EventId id)               { /* DELETE WHERE id = ? */ }
    protected override NostrEvent? TryGetRaw(EventId id)           { /* SELECT WHERE id = ? */ }
    protected override IEnumerable<NostrEvent> ScanByAuthorAndKind(PublicKey author, int kind) { /* for replaceable upsert */ }
    protected override IEnumerable<NostrEvent> ScanByAuthorKindAndIdentifier(PublicKey author, int kind, string identifier) { /* for addressable upsert + a-tag deletion */ }
    protected override IEnumerable<NostrEvent> ScanForQuery(Filter filter) { /* push as much of `filter` into SQL as possible */ }
    protected override int CountRaw()                              { /* SELECT COUNT(*) */ }
    protected override void OnDispose()                            { /* close connection */ }
}

EventStoreBase owns everything else:

  • NIP-01 dedup by event id.
  • Replaceable upsert for kinds 0, 3, 10000–19999 keyed by (kind, author); calls your ScanByAuthorAndKind, compares created_at, decides Stored / Replaced / Outdated.
  • Parameterized-replaceable upsert for kinds 30000–39999 keyed by (kind, author, d-tag); same logic via ScanByAuthorKindAndIdentifier.
  • NIP-09 tombstones — e-tag deletions add to an in-memory tombstone set; future StoreAsync calls for the same id return Deleted. The tombstone set is rehydrated lazily on first use by scanning your persisted kind-5 events via ScanForQuery, so persistent backends get correct semantics across restarts without needing their own tombstones table.
  • NIP-09 a-tag deletions — evicts the matching addressable if its created_at is older than the deletion; doesn't tombstone (newer events at the same address are still storable).
  • NIP-40 expiration — events with expiration in the past are dropped at store time and filtered from queries as wall-clock advances.
  • Ephemeral fan-out (kinds 20000–29999) — never persisted; delivered live to ObserveAsync subscribers.
  • Observer registry + snapshot+live merge for ObserveAsync.
  • Write serialization via an internal SemaphoreSlim. Reads are lock-free; your primitives must be thread-safe to be called concurrently with each other and concurrent with the (locked) write path.

MemoryEventStore is the reference subclass — ~140 lines, all "translate the primitives to a ConcurrentDictionary." Adding a SQLite or LiteDB backend is roughly the same shape: write the schema, translate the seven primitives to your storage API, ship. The MemoryEventStore test suite under tests/NostrNet.Relay.Tests/Storage/ doubles as an interop suite — point your subclass at the same tests and you've got a compliant store.


Lower-level access

Direct relay control

NostrClient is built on RelayPool, which is built on RelayClient. Drop down a layer when you need per-relay control:

await using var pool = new RelayPool();
var failures = await pool.ConnectAsync(new[]
{
    new Uri("wss://relay.damus.io"),
    new Uri("wss://nos.lol"),
});
foreach (var (uri, error) in failures)
    Console.WriteLine($"{uri} failed: {error.Message}");

var results = await pool.PublishAsync(signedEvent);

await foreach (var msg in pool.SubscribeAsync("sub1", new[] { filter }))
{
    switch (msg)
    {
        case SubscriptionEventReceived e:        /* event */ break;
        case SubscriptionEndOfStoredEvents:      /* all stored delivered */ break;
        case SubscriptionClosed c:               /* server closed */ break;
    }
}

Raw NIP-44 encryption

using NostrNet.Crypto;

string ciphertext = Nip44.Encrypt("hello", senderKey, recipientPubKey);
string plaintext = Nip44.Decrypt(ciphertext, recipientKey, senderPubKey);

// Cacheable conversation key (HKDF-Extract over ECDH x-coord)
Span<byte> ck = stackalloc byte[32];
Nip44.DeriveConversationKey(senderKey, recipientPubKey, ck);

Sample CLI

A working command-line app lives in samples/NostrNet.Sample.Console.

# Generate a fresh keypair
dotnet run --project samples/NostrNet.Sample.Console -- gen

# Post a note
dotnet run --project samples/NostrNet.Sample.Console -- post nsec1... "hello"

# Send a NIP-17 DM
dotnet run --project samples/NostrNet.Sample.Console -- dm nsec1... npub1... "hey"

# Listen to your own feed for 30 seconds
dotnet run --project samples/NostrNet.Sample.Console -- feed nsec1... --seconds 30

# Mine a 20-bit PoW note and publish
dotnet run --project samples/NostrNet.Sample.Console -- mine nsec1... "PoW message" 20

# Fetch a relay's NIP-11 document
dotnet run --project samples/NostrNet.Sample.Console -- info wss://relay.damus.io

# Verify a NIP-05 identifier
dotnet run --project samples/NostrNet.Sample.Console -- verify npub1... bob@example.com

# Vanity: 20-bit pubkey PoW
dotnet run --project samples/NostrNet.Sample.Console -- vanity-pow 20

# Vanity: npub starting with "alce" (bech32 chars only)
dotnet run --project samples/NostrNet.Sample.Console -- vanity-npub alce

# Vanity: pubkey hex ending with "dead"
dotnet run --project samples/NostrNet.Sample.Console -- vanity-hex dead --suffix

# Marmot + OpenMLS: in-tree Alice/Bob 1:1 conversation smoke test
dotnet run --project samples/NostrNet.Sample.Console -- marmot-mls-smoke

# Marmot interactive REPL on real relays
dotnet run --project samples/NostrNet.Sample.Console -- marmot-chat nsec1... [opts]

marmot-chat — interactive REPL over real relays

marmot-chat drives a full Marmot conversation end-to-end through the high-level NostrMarmotClient façade. Run two instances (one per identity) and they'll discover each other via real relays.

Options:

Flag Meaning
--state-path <file> SQLite path to persist MLS state across restarts (default: in-memory; lost on exit)
--peer <npub> Fetch the peer's KeyPackage and start a 1:1 immediately on launch
--relay <wss-uri> Add a relay (repeatable; default = the same 3 public relays as the other commands)
--auto-accept Auto-accept incoming invites instead of queueing them for /accept

REPL commands:

Command Effect
<text> Send <text> to the active conversation
/list List joined conversations (the active one is marked *)
/switch <N> Make conversation #N the active one for sends
/accept <N> Accept the Nth pending invite (skip with --auto-accept)
/start <npub> Fetch the peer's KeyPackage and start a 1:1
/add <npub> Add a peer to the active conversation (publishes a Commit)
/rotate Rotate your MLS leaf keys in the active conversation
/help Print the REPL command list
/quit Exit

Two-terminal demo (Alice ↔ Bob):

# 1. Generate two identities (run twice, save outputs):
dotnet run --project samples/NostrNet.Sample.Console -- gen

# 2. Terminal A — Bob waits to be invited:
dotnet run --project samples/NostrNet.Sample.Console -- \
  marmot-chat <bob-nsec> --state-path bob.db --auto-accept

# 3. Terminal B — Alice starts a 1:1 with Bob:
dotnet run --project samples/NostrNet.Sample.Console -- \
  marmot-chat <alice-nsec> --state-path alice.db --peer <bob-npub>

On launch each side publishes a KeyPackage and starts streaming inbound events. Alice's --peer flag fetches Bob's KeyPackage and publishes the NIP-59 Welcome; Bob's --auto-accept joins on receipt. After that, plain-text lines on either side are sent to the active conversation; the other terminal prints them as [#<conv> npub1…] <text>.

State persisted via --state-path survives restarts (MLS epochs, ratchet state, signature keys — all stored in SQLite via the in-tree Rust FFI bridge to OpenMLS). Drop the flag for ephemeral in-memory state.


Threading model

All I/O is async; the library never blocks the calling thread on a network operation. RelayClient runs its send and receive loops on the thread pool internally, so WebSocket traffic doesn't share a thread with your UI or request handler.

Async I/O is safe from any thread

PublishAsync, SubscribeAsync, Nip05.VerifyAsync, RelayInformation.FetchAsync, and friends are all properly async — await them from anywhere. Continuations marshal back to the caller's SynchronizationContext by default, so on a UI thread you can touch UI state directly after the await.

private async void PostButton_Click(object sender, EventArgs e)
{
    var results = await client.PostNoteAsync(textBox.Text);
    statusLabel.Text = results.Values.Any(r => r.Accepted) ? "Posted" : "Rejected";
}

For pure background work (worker services, console apps), add .ConfigureAwait(false) to keep continuations on the thread pool.

Subscriptions: await foreach doesn't block the thread, but does park the method

SubscribeAsync returns IAsyncEnumerable<ReceivedEvent> and yields each relay's delivery as it arrives. The UI thread stays responsive during a subscription (the message pump keeps running between awaits), but any code after the await foreach won't run until the subscription ends — when all relays close it, your CancellationToken fires, or the stream completes naturally.

// Code after the loop is parked until the subscription ends.
async Task RunFeedAsync(CancellationToken ct)
{
    await foreach (var received in client.SubscribeNotesAsync(authors: [pub], cancellationToken: ct))
        feedListBox.Items.Add(received.Event.Content);

    statusLabel.Text = "Subscription closed";   // runs only after the loop ends
}

If you want other work to proceed while the subscription runs, fire it on a separate task:

private void Start_Click(object sender, EventArgs e)
{
    _ = ConsumeFeedAsync(_appCts.Token);   // fire-and-forget
    statusLabel.Text = "Listening...";     // runs immediately
}

private async Task ConsumeFeedAsync(CancellationToken ct)
{
    try
    {
        await foreach (var received in client.SubscribeNotesAsync(authors: [pub], cancellationToken: ct))
            feedListBox.Items.Add(received.Event.Content);
    }
    catch (OperationCanceledException) { /* clean shutdown */ }
}

For multi-consumer or producer/consumer scenarios, decouple via a Channel<T>:

var feed = Channel.CreateUnbounded<ReceivedEvent>();

_ = Task.Run(async () =>
{
    await foreach (var received in client.SubscribeAsync(filters, ct).ConfigureAwait(false))
        await feed.Writer.WriteAsync(received, ct).ConfigureAwait(false);
}, ct);

// One or more consumers, possibly on different threads
await foreach (var received in feed.Reader.ReadAllAsync(ct))
    ProcessEvent(received.Event, received.Relay);

CPU-bound work: wrap in Task.Run

ProofOfWork.Mine is synchronous and CPU-bound — it will block the calling thread until it finds a satisfying nonce. Wrap it in Task.Run for UI apps or anywhere blocking is unacceptable:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

NostrEvent signed = await Task.Run(() =>
{
    var mined = ProofOfWork.Mine(template, targetDifficulty: 20, cts.Token);
    return mined.Sign(key);
}, cts.Token);

await client.PublishAsync(signed);

The library deliberately ships only the synchronous Mine — wrapping it in a MineAsync would just be Task.Run(() => Mine(...)), which the caller can do better themselves (they know their threading model). See Stephen Toub on async wrappers over sync methods.

NIP-44 encrypt/decrypt is synchronous too but typically fast (microseconds for small messages); only wrap in Task.Run if you're encrypting maximum-size payloads (64 KiB) on a UI thread and care about smoothness.

Concurrent operations on a single client are safe

You can have many concurrent subscriptions and in-flight publishes on the same RelayClient or RelayPool. Internal state uses ConcurrentDictionary and the send queue is an UnboundedChannel<string> configured for multiple writers. The same NostrClient instance is shared across your app — don't create one per call.

Cancellation

Every async method takes a CancellationToken. Wire one app-scoped CancellationTokenSource to your shutdown signal and pass it everywhere:

using var appCts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { appCts.Cancel(); e.Cancel = true; };

await using var client = await NostrClient.Builder(key)
    .UseRelays("wss://relay.damus.io")
    .ConnectAsync(appCts.Token);

// ... run subscriptions / publishes / mining with appCts.Token ...

await using var client ensures the receive/send loops are torn down and the WebSocket is closed cleanly. Pending publishes fail with OperationCanceledException; subscription enumerators complete; Mine throws on its next iteration check.

Design notes

  • AOT-safe. All JSON uses System.Text.Json source generators (JsonSerializerContext). No reflection-based serialization. Trim and AOT analyzers are on; AOT/trim warnings fail the build.
  • Strong types over strings. PublicKey, PrivateKey, EventId, and Signature are distinct types. The compiler refuses to swap them. An UnsignedEvent cannot be published — only signing produces a NostrEvent, which is what RelayClient.PublishAsync accepts.
  • Span-based crypto, no ambient state. No DI required. No static registration. Construct what you need, pass it where needed.
  • Memory hygiene. PrivateKey zeros its buffer on Dispose. All intermediate buffers in NIP-44 are zeroed. ToString() on PrivateKey returns "PrivateKey(****)".
  • Async-first. Everything I/O-bound is Task / ValueTask with optional CancellationToken. Event streams are IAsyncEnumerable<T>.

Dependencies

Managed (all packages)

The pure-managed packages — Core, Crypto, Relay, Client, Blossom, Marmot — ship with one external NuGet dependency:

  • NBitcoin.Secp256k1 — a pure managed implementation of secp256k1 (BIP-340 Schnorr, ECDH). Wrapped behind an internal seam in NostrNet.Core/Secp256k1/ so the choice of curve library is a single-file swap.

Everything else uses the BCL: System.Net.WebSockets, System.Net.Http, System.Security.Cryptography (HKDF, HMAC-SHA256, SHA-256, AES, CSPRNG), System.Text.Json.

Native (NostrNet.Marmot.Mls.Native only)

The MLS engine for Marmot is OpenMLS 0.8 — an RFC 9420-compliant MLS implementation in Rust. NostrNet bridges to it through an in-tree C ABI cdylib (nostrnet-marmot-native/) that also embeds:

  • openmls_sqlite_storage — OpenMLS's StorageProvider backed by SQLite, for persistent ratchet state.
  • rusqlite with the bundled feature — compiles SQLite from C source. Used both by openmls_sqlite_storage and by a second connection that maintains the Marmot-specific nostr_group_id ↔ MLS GroupId map.

The published NuGet ships pre-built binaries for six RIDs (osx-x64, osx-arm64, linux-x64, linux-arm64, win-x64, win-arm64); NuGet selects the matching one at restore. You do not need Rust or a C compiler to consume this package — only to build it from source.

License

MIT.

About

Nostr Library .NET

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages