Original release notes
GRAIN v0.8.0
A Nostr client, the library it's built on, and a relay hardened in production Release 10.4.2026 Full Changelog: https://github.com/0ceanSlim/grain/compare/v0.7.1...v0.8.0
---
v0.7 made the relay something you run from a browser. v0.8 turns grain into a full Nostr client as well: an importable Go client library that speaks the outbox model, and a web client built on it that manages your profile, relays, media servers, encryption and sign-in with your own key.
The relay grew up alongside it. Six release candidates ran on a live public relay between June and October, and what that turned up is fixed here. Most important: grain was silently losing some events it had accepted. Any event containing &, , which includes every URL with a query string, was acknowledged with OK true and never stored. A few rarer event shapes failed the same way. 0.8.0 stores them all, and an event the database can't take is now refused out loud instead of dropped. Retention also counts from when the relay received an event, request filters are strict, and the dashboard shows the relay's health at a glance.
---
The client library (client/core)
An importable, outbox-model Nostr client engine in pure Go, with no cgo and no web dependencies. A downstream app builds its own client on it instead of reimplementing relay routing.
- Outbox routing, automatic. Name the intent and the engine picks the relays. Reads come from a user's NIP-65 outbox. A published note reaches the author's outbox plus each mentioned recipient's inbox. Metadata resolves from the indexers. A leased connection pool keeps routing additive, so switching a session's relays adds connections rather than tearing the pool down.
- A role model for relays. Every relay carries a
Rolebitmask: outbox, inbox and DM inbox (per target, from NIP-65 and NIP-17), search, blocked, favorite and private (the user's own NIP-51/37 lists), and locally configured indexer, broadcast and trusted roles. - Streaming fetches. A multi-relay streaming primitive delivers events on a channel as each relay answers, de-duplicated by id. Feeds hydrate lazily from it.
- Events resolve across relays. Opening an event escalates from the local relay to the author's outbox relays, the event's relay hints, and NIP-50 search, so an event stored elsewhere still resolves.
- Relay discovery from NIP-66 monitors.
DiscoverRelays,DiscoveredRelaysandStartDiscoveryRollexpose the monitor-backed relay set described below. - Pluggable seams. Bring your own
Signer(local key, NIP-46 or hardware),Logger(any*slog.Logger) andRelayListStore(in-memory by default). A read-only context needs no signer. Reads and publishes take acontext.Context. - Fast and frugal. Relays are dialed concurrently, reads return as soon as relays send EOSE, metadata lookups no longer fan out to the whole pool, and a publish redials a dropped relay for up to 10 seconds before giving up.
- Fixed-relay opt-out. A pinned single-relay client can fix its read and write set, which deliberately disables the outbox model. Off by default.
The header shows a live x/y relays pool count, and GET /api/v1/client/status reports the pool's stats, including discovered monitors and relays.
For developers: the importable surface
import "github.com/0ceanslim/grain/client/core"
client := core.NewClient(core.DefaultConfig()) // owns the shared pool
signer, _ := core.NewEventSigner(privKeyHex) // or your own Signer
uc := client.NewUserContext(signer.PublicKey(), core.WithSigner(signer))
notes := uc.FetchNotes(ctx, author, core.WithLimit(20)) // author's outbox
reply, results, _ := uc.Reply(ctx, parent, "well said!") // outbox parent's inbox
UserContexthas intent methods:FetchNotes,StreamNotes,Reply,PublishandSignAndPublish.- Routing is inspectable: ask the engine which relays it would use without doing the operation.
- The full surface is documented in the client library guide, with compile-checked examples in
client/core/example_test.gothat run in CI.
---
Signing in
Sign-in runs through grain's own mill signer component (1.8.2), branded with your relay's name and icon from NIP-11.
- Platform-aware methods. Desktop leads with pomegranate (Google-backed FROST signing) and a browser extension (NIP-07). Android offers pomegranate and Amber (NIP-55). iOS offers pomegranate and a private key. A remote bunker (NIP-46), a private key and read-only mode sit in an Advanced menu.
- "I'm new here" sits at the top for first-time users, and a brand-new key opens an empty, editable profile with a banner that walks you through setting up relays.
- An existing key can be restored by importing it.
- The relay's terms and privacy links show in the modal when NIP-11 advertises them.
Profile, relays and settings
- Edit your profile in place. Click into your name, bio, picture, banner or any field to edit it. An Advanced editor exposes the raw fields and drag-to-reorder tags. Edits are signed with your key and published to the indexers plus your own relays, with a live per-relay acceptance count that also shows signer errors.
- Settings in five tabs: My Relays, Discovery, Media, Security and Account. My Relays splits into Mailboxes (NIP-65 outbox/inbox and NIP-17 DM), Lists (search, favorites, blocked, private) and App (indexer, broadcast and trusted relays, and the fixed-relay override). Links like the header's "Manage relays" open the right tab.
- Relay lists load at sign-in, along with your media servers, so settings open instantly. Your own changes show on the page as they happen, over a server-sent-events channel.
- A known-relays browser with live status, NIP-11 details on expand, a "fastest" latency sort, and one-click staging into any of your lists. Every add-relay field autocompletes from the known set.
- Relay AUTH (NIP-42). Relays that send a challenge are listed. One click signs it, an authenticated relay stays trusted until you remove it, and a new challenge prompts again.
- Private relay lists. NIP-37 and encrypted NIP-51 lists have a decrypt button that reveals private entries with your signer. grain never sees the plaintext.
- Client tags (NIP-89). Published events carry
["client", "grain"]and any other app's client tag is stripped first. The relay sets the default and users can opt out.
Media
- Resolve and edit your Blossom (kind 10063) and NIP-96 (kind 10096) server lists.
- Uploads open an options dialog: pick the primary server, mirror to others, preview the file, and see a warning for ephemeral storage. Uploads are signed client-side (a Blossom BUD-01 or NIP-96 authorization) and show live per-server progress. Uploading works from the profile editor and the admin image fields.
- The recommended media servers point at oslim.dev.
Encryption
- Native NIP-44 encryption, validated against the official test vectors. v2 is the default. v3, the in-progress draft that binds the event kind and a scope into the MAC, is opt-in.
Relay discovery (NIP-66)
The relay browser shows a bounded, liveness-backed set of relays discovered from NIP-66 monitors, with no hardcoded monitor list.
- Finds its own monitors from kind 10166 announcements across the relays it already uses, then reads each monitor's kind 30166 relay records: URL, round-trip time, supported NIPs, network, requirements and location.
- Trusts by consensus. Monitors whose reports are mostly uncorroborated are dropped, and a relay must be reported by enough trusted monitors to be listed.
- Heals itself. A periodic pass re-discovers monitors and drops any whose data has gone stale.
- The relay browser shows each relay's NIP-66 details and can filter to monitored only or hide auth/paid relays.
- The Discovery tab shows how many monitors grain trusts and how many relays they report, with a Refresh discovery button (
POST /api/v1/client/discover).
Homepage and look
- A live feed of events arriving at this relay, newest first, with author, kind and a snippet, lazy-loading older events as you scroll. It restarts cleanly when you navigate away and back.
- grain's own theme is the default: warm dark "wheat-field" surfaces with a green accent. Dark is now a true neutral dark, and Midnight and Matrix remain. The installed-app color matches.
- The header stays pinned while you scroll. Single events open faster: the lookup returns as soon as the event is found.
- Unknown pages return 404 (the app still renders, so a mistyped link lands on the home page), and
/favicon.icoserves the icon.
---
Events are no longer lost
Several kinds of valid event used to be acknowledged with OK true and then thrown away by the database, with nothing in the logs. All are fixed:
&,anywhere in an event. grain encoded events for its database (nostrdb) with Go's default JSON encoder, which writes those characters as\u0026-style escapes that nostrdb can't parse. That covered URLs with query strings,A & Bin text, and many profiles. Replaceable events were worse: the old version was deleted before the new one was dropped, so a profile update containing&erased the profile.- Control characters. Event ids are now computed the way nostr-tools, go-nostr and rust-nostr compute them: the control characters without a short escape are written as lowercase
\u00XX. It's checked against nostr-tools for all 32. nostrdb computes them the same way, so such events are stored, not just accepted. - Uppercase hex in a tag. nostrdb stored any 64-character hex tag value as a binary id and wrote it back lowercase, which changed the event. Only lowercase hex is packed now.
- Tags spelled like event fields. nostrdb's parser read strings inside tags as top-level fields, so a tag such as
["pubkey", ],["kind", ...]or a#kindhashtag rewrote or broke the event. It now reads only the event's own fields. - Addressable events without a
dtag were refused. As NIP-01 says, a missing or emptydtag now counts as the empty string, so those versions replace one another. - Nothing is dropped silently any more. Before an event is queued, and before any replace or delete, grain runs nostrdb's own parser and id check on it. An event the database would drop gets
OK falseand an ERROR line that includes the event, as does an event whose id doesn't match its content. Events are also sent to clients with&,unescaped, so clients that read relay messages with nostrdb (Damus, Notedeck) don't drop them. - Events lost before this release were never written, so nothing can be recovered. Each author's next publish lands.
Durability
- Writes are refused before the storage map fills. A full LMDB map used to make the background writer fail after the client was told
OK. grain now tracks map usage, logs a WARN at 80% and an ERROR at 95%, and from 97% refuses new events witherror: relay storage unavailableso retention can still run. - Writer failures are visible. Every failed database write is counted. grain logs when the count climbs and shows it on the dashboard as writer health.
- Stable reads under load. The database opens with
MDB_NOTLSand more reader slots, so thread migration can't exhaust reader slots, and stale slots left by dead processes are reclaimed at startup. - A 64 GB default map (
map_size_mb: 65536) replaces the old 4 GB, which a busy relay filled quickly. It's a reservation, not disk use, and existing configs are raised on load. - No crash-loop under subscription churn. A relay message racing a closing subscription could panic and restart the process. It's now dropped safely.
- Lower write latency. Logging writes asynchronously without a per-write fsync, and per-request logging on the hot path is gone.
nostrdb+
grain stores events in its own fork of nostrdb, now called nostrdb+ (0ceanSlim/nostrdb). Upstream nostrdb is built as an embedded database for clients like Damus and Notedeck. A relay needs more from it: real deletes, a way to see failed writes, and relay-side query behavior. nostrdb+ adds those and stays in sync with upstream.
What nostrdb+ adds to nostrdb
- Real deletes. A delete removes the note and every index entry that points at it. It backs NIP-09 deletions, purge, and replacing replaceable and addressable events. 0.7.1 had a basic delete; 0.8.0's reverses every index.
- Visible write loss. nostrdb+ reports how full the LMDB map is and counts every failed write, which is what lets grain refuse events before the map fills and show writer health.
- No dropped ingest. The queue from the ingesters to the writer blocks when full instead of dropping events.
- Stable reads under load. The database opens with
MDB_NOTLSand 512 reader slots. - Configurable full-text kinds for NIP-50 search.
- NIP-01 id and author prefix filters, and an
untilthat includes events at exactly that second. - Windows build fixes.
Fixed in nostrdb+ since 0.7.1
- Event ids with control characters are computed the way clients compute them.
- Only lowercase 64-character hex tag values are packed into binary ids, so an uppercase value comes back unchanged.
- The note parser reads only the event's own fields, so a tag spelled like a field no longer rewrites the event.
- A reposted note that is re-ingested during shutdown is no longer stranded.
The first three are the database side of the "Events are no longer lost" fixes above.
From upstream (synced to damus-io 5dbd1ce)
- Stale reader slots left by dead processes are cleared at startup.
- Queries that span several kinds or authors merge their index scans, so results come back newest-first.

