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!");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.
| 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 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 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.
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.
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).
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.)
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:
- .NET 10 SDK — install from https://dotnet.microsoft.com/download/dotnet/10.0.
- Rust toolchain — install rustup:
(On Windows, download
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup-init.exefrom https://rustup.rs/ and run it.) rustup installs the stable toolchain and addscargo+rustcto 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 themsvctoolchain, 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.slnxThe 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.
| 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.
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.
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()); // ...deadAll 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.
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.
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.
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 hexPrivateKey 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.
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.)
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.
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, orDisconnected.Reason— forDisconnected:Disposed(terminal — the client/pool was disposed),ConnectFailed(handshake error, DNS, TCP refused),TransportError(WebSocket errored after being open), orServerClosed(relay sent a clean close).Error— the underlying exception for transport errors / connect failures, otherwise null.AttemptNumber—1for 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.
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.
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.
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.
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;
}
}// 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 rumorFor 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.
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.
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 reasonThe 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.
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.
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.
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);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.
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.
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.
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.
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).
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.
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.
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");
}- The receive loop captures each
["AUTH", "<challenge>"]into per-relay state (RelayClient.LatestAuthChallenge). - When auto-auth is on and a key is set,
RelayClientfires a backgroundTask.Runthat signs the kind-22242 event and sends["AUTH", <event>]. - Publishes: if
PublishAsyncgets a rejection whose message starts withauth-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
RelayPoolwatches forCLOSED auth-required. When it sees one (with auto-auth on), it waits for AUTH then transparently re-issues theREQ— the consumer'sawait foreachkeeps 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.
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 finishesFor 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", ...].
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.
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.
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.
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);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. |
| 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
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.
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 yourScanByAuthorAndKind, comparescreated_at, decidesStored/Replaced/Outdated. - Parameterized-replaceable upsert for kinds 30000–39999 keyed by
(kind, author, d-tag); same logic viaScanByAuthorKindAndIdentifier. - NIP-09 tombstones —
e-tag deletions add to an in-memory tombstone set; futureStoreAsynccalls for the same id returnDeleted. The tombstone set is rehydrated lazily on first use by scanning your persisted kind-5 events viaScanForQuery, so persistent backends get correct semantics across restarts without needing their own tombstones table. - NIP-09
a-tag deletions — evicts the matching addressable if itscreated_atis older than the deletion; doesn't tombstone (newer events at the same address are still storable). - NIP-40 expiration — events with
expirationin 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
ObserveAsyncsubscribers. - 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.
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;
}
}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);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 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.
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.
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.
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);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.
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.
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.
- AOT-safe. All JSON uses
System.Text.Jsonsource 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, andSignatureare distinct types. The compiler refuses to swap them. AnUnsignedEventcannot be published — only signing produces aNostrEvent, which is whatRelayClient.PublishAsyncaccepts. - Span-based crypto, no ambient state. No DI required. No static registration. Construct what you need, pass it where needed.
- Memory hygiene.
PrivateKeyzeros its buffer onDispose. All intermediate buffers in NIP-44 are zeroed.ToString()onPrivateKeyreturns"PrivateKey(****)". - Async-first. Everything I/O-bound is
Task/ValueTaskwith optionalCancellationToken. Event streams areIAsyncEnumerable<T>.
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 aninternalseam inNostrNet.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.
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'sStorageProviderbacked by SQLite, for persistent ratchet state.rusqlitewith thebundledfeature — compiles SQLite from C source. Used both byopenmls_sqlite_storageand by a second connection that maintains the Marmot-specificnostr_group_id↔ MLSGroupIdmap.
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.
MIT.
