Repository navigation
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: 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 &, < or >, 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 inboxUserContexthas 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:
&,<or>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", <hex>],["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&,<and>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.
- The profile search index sorts correctly. Rebuilding it is the v5 to v6 migration that runs on first start.
ndb_compact()for selective compaction, which grain doesn't use yet.
⏳ Retention
- Purge actually drains. It used to re-scan the same newest events every run and never reach the backlog. It now pages through each kind oldest-first, up to 100,000 deletes per run.
- Age counts from arrival. By default (
event_purge.retention_clock: received), an event that arrives more thanlate_arrival_minutes(default 10) after itscreated_atis aged from when the relay received it. A republished profile or a backfilled note gets the full keep window instead of disappearing at the next purge. Arrival times are kept inarrivals.lognext to the database.retention_clock: created_atrestores the old behavior. - Purge reaches every kind. It used to find kinds by sampling the newest 50,000 events, so a kind whose events were all older was never purged. It now checks every kind number.
keep_kindslists kinds that are never purged, even when their category is.- All retention settings, including the new clock settings, are editable in the dashboard's Event Purge section.
🛡️ Protocol behavior
- Strict filters. A malformed REQ or COUNT filter (a string kind, a 65-character author, a non-hex id, a negative limit) used to be ignored, which widened the query to the whole database. It now gets
CLOSED "invalid: filter N: ...". An emptyids,authors,kindsor#taglist matches nothing, as in nostr-tools and go-nostr. - Id and author prefixes of 1–64 hex characters are matched (NIP-01), with an advisory NOTICE on the first prefix query of a connection.
- Subscriptions: at
max_subscriptions_per_client, a new subscription replaces the oldest and the relay sendsCLOSEDfor it. A client's own CLOSE gets no reply, as NIP-01 reservesCLOSEDfor subscriptions the relay ends. NOTICE frames are["NOTICE", <message>]. - NIP-77 isn't supported yet.
NEG-OPENnow gets an immediateNEG-ERR, so clients fall back to REQ instead of timing out. - NIP-09 deletions: a deletion with a malformed
eoratarget is refused withinvalid:. A deletion that names another author's event leaves it in place without logging an error. - NIP-50:
database.fulltext_kindschooses which kinds are full-text indexed (default 0, 1 and 30023). An empty list turns the index off. - NIP-11 reports your real limits:
max_message_length,max_subscriptions,max_limit,auth_required,restricted_writes,max_content_lengthand thecreated_atbounds.relay_countries,language_tagsandtagsare editable from the dashboard. - A well-formed filter the database can't represent is skipped and the subscription's other filters still run, instead of the whole REQ failing.
📊 Dashboard and operations
- A vitals panel: event count, connections, uptime, storage used against the map ceiling, Go heap against its limit, and writer health.
- A top-kinds donut that ranks by count or stored size, with a hover tooltip.
- Tabs: Overview, Access lists, Policy & Limits and Retention.
GET /api/v1/relay/statsbacks the panel. It's public and served from a 30-second cache, so calls don't scan the database. Itsby_kindbuckets report lists as kind 30000.- A Ban button for the relay owner. Signed in as the owner, any author's profile shows 🚫 Ban, which adds them to the blacklist through NIP-86
banpubkey. - New admin sections: client config, Relay information, and Operations at the top.
- Config repairs itself: older config shapes are corrected on load, the admin forms show the running config, and size-limit changes apply on reload. A brief NIP-11 hiccup no longer shows a false "relay unclaimed" banner.
total_messages_sentis a running total.- Logging:
--data-dircovers every log line from the first one. Startup logs how long the database open, the startup purge and the expiration bootstrap took. A config change that restarts the relay says so.
📚 Docs
- The client library guide covers the full 0.8.0 surface, including NIP-66 discovery, with compile-checked examples.
- The API docs at
/api/docscover the client endpoints, and "Try it out" signs its NIP-98 auth with your connected signer. - Docker setup is web-UI-first, the unused
GRAIN_ENVvariable is gone, and the README points to a docs hub with the stale links fixed.
⬆️ Upgrading from v0.7.1
- Database migration. nostrdb upgrades the database from v5 to v6 on first start and rebuilds its profile search index. Expect the first start on a large database to take longer; the startup timings show where the time goes.
- The storage map default is 64 GB. Existing smaller values are raised on load.
- Purge counts age from arrival by default. Set
event_purge.retention_clock: created_atto keep the old behavior. The first purge may delete more than usual, because kinds the old sampling missed are now reached. - Malformed filters now fail. Clients that relied on a malformed filter returning results get
CLOSED "invalid: ...". - New config keys, all optional:
event_purge.retention_clock,event_purge.late_arrival_minutes,event_purge.keep_kinds,rate_limit.max_content_lengthanddatabase.fulltext_kinds. Older config shapes are corrected on load. Open the dashboard and save a section to write the corrected shape to disk. - grain embeds nostrdb+ (see above). The release binaries include it, and source builds compile it from the submodule.
Issues: #56 · #71 · #72 · #74 · #77 · #80 · #83 · #87 · #90 · #98 · #99 · #100 · #101 · #102 · #104