Floonet
Floonet is a network of Nostr relays for the Grin community. Anyone can run one, and anyone can run a name authority on one so people can claim (and optionally pay for) a name.
A Floonet relay is an ordinary Nostr relay with strong opinions. It stores only the handful of event kinds the Grin ecosystem actually uses, it says nothing about payments in its public metadata, it welcomes connections arriving through the Nym mixnet, and it ships hardened by default. Wallets like Goblin use Floonet relays to deliver gift-wrapped Grin payments and to resolve names like alice.
The two packages
Floonet ships as two relay packages. Both carry the same conventions; pick the one that fits how you like to operate.
| Package | Base | Shape |
|---|---|---|
| floonet-strfry | strfry (C++) | Stock strfry at a pinned ref plus a spec: a modular write-policy plugin, a bundled name authority, and a TLS proxy, deployed as one Docker Compose unit. |
| floonet-rs | nostr-rs-relay (Rust) | A single binary with an installer and a hardened systemd unit. Policy lives in a composable admission module; the name authority and the GoblinPay payment processor are built in. |
Both add the same four features, each configurable, optional, and modular:
- An event-kind whitelist (the keystone: default deny, see below).
- Authentication: NIP-42 plus pubkey whitelists.
- Paid access and paid names via GoblinPay (Grin).
- A name authority: the NIP-05 service that maps names to keys.
The whitelist keystone
The single most important design decision in Floonet is default deny. A Floonet relay accepts only the event kinds it has been explicitly told to allow, and drops everything else. The initial allowed set is exactly what a Grin payment wallet needs:
| Kind | What it is |
|---|---|
0 | Profile metadata |
3 | Contact list |
5 | Deletion request (NIP-09) |
13 | Seal (NIP-59) |
1059 | Gift wrap (NIP-59): the sealed envelope payments travel in |
10002 | Relay list (NIP-65) |
10050 | DM relay list (NIP-17) |
27235 | HTTP auth (NIP-98): used by the name authority |
Everything else, notes, reactions, long-form content, is rejected. This keeps a Floonet relay lean, cheap to run, and uninteresting to abuse. The list is one editable config value in both packages, so it can grow later without code changes. See The whitelist: default deny.
How to read these docs
- Concepts: the ideas every operator should know, whichever package they run.
- floonet-strfry and floonet-rs: deploy, configure, and extend each package.
- Operate: hardening, rate limits, and charging GRIN for relay resources.
- Reference: config keys, endpoints, and the allowed-kinds table.
Where these docs cite code, they use file:line references into the package source or the pinned upstream so you can read along.
The whitelist: default deny
Summary. A Floonet relay is default-deny: it accepts only the event kinds on its allow-list and drops everything else. The list is one editable config value in both packages, enforced fail-closed in the write path.
Motivation
A general-purpose Nostr relay stores whatever anyone throws at it: notes, reactions, media metadata, bot spam. A payment relay does not need any of that, and storing it makes the relay bigger, slower, and a more attractive target. Floonet inverts the default: nothing is accepted unless it is explicitly allowed. Allow only what is needed now; expand later by editing config, not code.
The allowed set
The initial list matches what the Goblin wallet actually publishes and reads (the canonical list is whatever the wallet uses, in goblin/src/nostr/):
| Kind | NIP | Why a Floonet relay carries it |
|---|---|---|
0 | 01 | Profiles: display names and avatars for contacts |
3 | 02 | Contact lists |
5 | 09 | Deletion requests, so users can retract events |
13 | 59 | Seals: the inner layer of a gift wrap |
1059 | 59 | Gift wraps: the opaque envelopes everything private travels in |
10002 | 65 | Relay lists: where a user can be found |
10050 | 17 | DM relay lists: where to deliver private messages |
27235 | 98 | HTTP auth events, used to register names with the name authority |
See the allowed kinds reference for the full table.
How each package enforces it
- floonet-strfry keeps policy in strfry’s write-policy plugin, strfry’s intended extension point (
strfry.confkeyrelay.writePolicy.plugin; seestrfry/docs/plugins.mdandsrc/PluginEventSifter.hupstream). The plugin checkskindagainst the allow-list first and rejects everything else, fail-closed: if the plugin cannot parse the event or reach its config, the answer is reject. The read side can additionally setfilterValidation.allowedKindsso disallowed kinds cannot even be subscribed to. - floonet-rs uses the upstream
event_kind_allowlistlimit (upstreamnostr-rs-relay/src/config.rs:54-79,config.toml:151-159) and enforces it in the write path inside the admission module, before the event reaches storage. Wrapping the check in the admission layer means it composes cleanly with auth and paid checks.
Behavior on rejection
Dropped events are silently rejected by default (a shadowReject in strfry terms). A spammer learns nothing about why their event vanished; a legitimate wallet never publishes disallowed kinds in the first place.
Growing the list
ALLOWED_KINDS is a single config value: a comma-separated list of integers in the plugin config (strfry) or event_kind_allowlist in config.toml (rs). If you run a relay for a community that also wants, say, public chat, add the kind and restart. One rule for operators upgrading an existing relay: never narrow the list below what live wallets already depend on.
References
- Enforcement: The write-policy plugin, The admission module.
- The full table: Allowed kinds.
- Kind and NIP details: https://nostrbook.dev/.
Neutral relay metadata (NIP-11)
Summary. A relay’s public NIP-11 information document (name, description, supported NIPs, software) never mentions payments, transactions, slatepacks, or money. Floonet relays only ever see opaque ciphertext, so payment wording would be both inaccurate and a liability. The shipped defaults are neutral Floonet branding.
Motivation
NIP-11 is the JSON document a relay serves when an HTTP client asks for application/nostr+json. It is the relay’s public face: crawlers index it, relay browsers display it, and anyone can fetch it.
A Floonet relay stores gift wraps (kind 1059), which are opaque, encrypted envelopes. The relay cannot know what is inside them. Describing the relay as handling “payments” would therefore be a claim it cannot verify about content it cannot read, and it would paint a target on the operator. So the rule is simple: the relay’s own metadata says nothing about payments.
Shipped defaults
| Package | NIP-11 name | NIP-11 description | Where it is set |
|---|---|---|---|
| floonet-strfry | Floonet Relay | A strfry Floonet relay. | relay.info.name / relay.info.description in strfry.conf |
| floonet-rs | floonet-rs-relay | A Floonet relay for the Grin community Nostr network. | the [info] block in config.toml |
Operators can customize both freely. The point is that the defaults carry zero payment language, so nobody ships a payment-labelled relay by accident.
The audit rule
The neutrality rule covers every relay-facing surface, not just NIP-11:
- The NIP-11
nameanddescription. - The HTML landing page the relay serves to browsers (both packages serve a neutral Floonet page with the Floonet logo).
- Any other served JSON.
- The example configs in the READMEs.
Operator documentation (like this book) may of course explain paid names and paid access. The relay’s own public metadata may not.
References
- NIP-11: https://nips.nostr.com/11.
- What the relay actually sees: Gift wraps.
- Charging for resources without advertising it on the relay: Charge GRIN for your relay.
Gift wraps: what a relay sees
Summary. Everything private on Floonet travels as a NIP-59 gift wrap: a kind
1059event encrypted with NIP-44 to a throwaway key, containing a kind13seal, containing the real message (NIP-17). A relay sees ciphertext, a random-looking recipient key, and a deliberately fuzzed timestamp. Nothing else.
The three layers
- The rumor. The actual message (for a wallet, a Grin slatepack riding a NIP-17 private direct message). It is never signed on its own, so it cannot be leaked and attributed.
- The seal (kind
13). The rumor is encrypted with NIP-44 to the recipient and signed by the real sender. The seal proves authorship to the recipient only. - The gift wrap (kind
1059). The seal is encrypted again, this time signed by a one-time throwaway key, and addressed to the recipient’s key in aptag. This is the only layer a relay ever stores.
What the relay can and cannot learn
A Floonet relay storing a gift wrap sees:
- A kind
1059event, signed by a key that will never be used again. - A
ptag naming the recipient key (which wallets rotate independently of their funds). - Ciphertext of unknowable content.
- A
created_atthat is deliberately wrong: NIP-59 tooling backdates both the seal and the wrap by a random amount up to two days, so the timestamp does not reveal real send time.
It cannot see the sender, the content, the amount, or whether the envelope is a payment, a message, or anything else. This is why NIP-11 metadata stays payment-neutral: the relay genuinely does not know.
One operational consequence: event size
Gift-wrapped slatepacks are much larger than typical Nostr notes. Both packages ship with a maximum event size large enough for wrapped slatepacks:
- floonet-strfry:
events.maxEventSizeinstrfry.conf. - floonet-rs:
max_event_bytesinconfig.toml.
Do not tighten these below the shipped defaults or wrapped payloads will bounce.
References
- NIP-17 (private DMs): https://nips.nostr.com/17.
- NIP-44 (encryption): https://nips.nostr.com/44.
- NIP-59 (gift wrap, kind 13 and 1059): https://nips.nostr.com/59.
- Kind pages: https://nostrbook.dev/kinds/1059.
Authentication (NIP-42)
Summary. Both packages support NIP-42 client authentication plus a pubkey whitelist. Auth is optional and configurable: run fully open, require auth to write, require auth to read, or restrict to a whitelist. Auth composes with the kind whitelist and the paid gate.
The NIP-42 flow
NIP-42 lets a relay learn, cryptographically, which key is on the other end of a websocket:
- The relay sends
["AUTH", "<challenge>"]. - The client answers with a kind
22242event carrying two tags:relay(the relay’s URL) andchallenge(the string from step 1), signed by the client’s key. - The relay validates the signature, the challenge, the relay URL, and that
created_atis recent (about a 10 minute window).
After that, the connection has an authenticated pubkey attached, and every policy decision can use it.
What each package does with it
- floonet-strfry: NIP-42 is native to strfry (
relay.auth.enabledandrelay.auth.serviceUrlinstrfry.conf; the kind22242challenge validation lives in upstreamRelayIngester.cpp). The write-policy plugin receives theauthedpubkey with every event, so auth checks, whitelist checks, and paid checks all live in one place: the plugin. - floonet-rs: NIP-42 is fully implemented upstream (the auth state machine in
nostr-rs-relay/src/conn.rs:18-228, and theAuthorization { pubkey_whitelist, nip42_auth, nip42_dms }config inconfig.rs:81-87). floonet-rs enforcespubkey_whitelistin the admission module, which the upstream parsed but did not gate writes on.
Modes
| Mode | Effect |
|---|---|
| off (default) | Anyone may read and write, subject to the kind whitelist |
| require auth to write | Unauthenticated publishes are rejected with auth-required: |
| require auth to read | Subscriptions require a completed AUTH first |
| whitelist only | Only the configured pubkeys may write, authenticated via NIP-42 |
All modes keep the kind whitelist in force; auth never bypasses it.
References
- NIP-42: https://nips.nostr.com/42.
- Config keys: Config keys reference.
The name authority
Summary. A name authority maps human names to Nostr keys via NIP-05, so people pay
aliceinstead of a 64-character key. Both Floonet packages ship one: registration is authenticated with NIP-98, names follow strict validation rules, each key holds at most one name, and operators may charge GRIN for names.
Motivation
Keys are unusable as addresses for humans. NIP-05 solves this with a well-known JSON file: https://example.org/.well-known/nostr.json?name=alice returns alice’s pubkey. A Floonet name authority is the small service that maintains that file, plus an API to claim and release names. Anyone can run one, on any Floonet relay, under any domain.
The rules
The rules are inherited from the goblin-nip05d reference implementation and are the same in both packages:
- Validation. Names are lowercase
[a-z0-9._-], must start and end alphanumeric, and are capped at 20 characters. - One active name per key. Enforced with a partial unique index in the database. Claiming a new name releases the old one.
- Reserved names. A reserved list plus domain-label reservation plus look-alike folding (so
a1icecannot impersonatealice). - Authenticated registration. Claiming or releasing a name requires a NIP-98 HTTP auth event (kind
27235withu,method, andpayloadtags), with a timestamp bound and a replay window, so a captured request cannot be replayed. - Cooldown. A key that changes its name must wait out a cooldown before changing it again.
The endpoints
| Endpoint | Purpose |
|---|---|
GET /.well-known/nostr.json?name= | NIP-05 resolution |
POST /api/v1/register | Claim a name (NIP-98 auth) |
DELETE /api/v1/register/{name} | Release a name (NIP-98 auth) |
GET /api/v1/by-pubkey/{pubkey} | Reverse lookup: which name does this key hold |
GET /api/v1/profile/{name} | Profile data for a name |
GET /api/v1/name/{name} | Availability check |
GET /api/v1/health | Health probe |
See the endpoints reference.
Free or paid
By default names are free. An operator can instead require a confirmed GoblinPay payment of FLOONET_NAME_PRICE_GRIN before a registration succeeds. The price is plain config; see Charge GRIN for your relay.
A note for wallet users
One wallet can hold multiple Nostr identities (npubs). If you pay for a name and want to keep it, load the same wallet seed in Goblin and switch to (or add) the npub that owns the name; different npubs and identities share one wallet.
References
- NIP-05: https://nips.nostr.com/5.
- Package specifics: bundled authority (strfry), in-process module (rs).
Nym privacy and the Floonet exit
Summary. Wallet traffic reaches Floonet relays through the Nym mixnet: five hops of cover traffic that hide who is connecting to whom, even from the relay. The Floonet stack can also operate its own Nym exit gateway, which wallets prefer as a reliable anchor while always keeping the public exit pool as fallback.
Why a mixnet in front of a relay
TLS hides content but not the connection itself: an observer (or the relay operator) can still see which IP talks to which relay and when. The Nym mixnet breaks that link. Wallet traffic is sphinx-packeted through five hops with cover traffic, so a Floonet relay sees connections arriving from mixnet exit IPs, not from users.
Two consequences for operators:
- You cannot rate limit by IP alone. Many users legitimately share one Nym exit IP. Floonet controls abuse per connection, not per IP. See Rate limits.
- You learn nothing from your own logs. Combined with gift wraps, a relay operator sees ciphertext arriving from anonymized connections. There is nothing to leak, subpoena, or sell.
The anchor + fallback exit
Public Nym exits vary in reliability. The Floonet stack therefore supports running its own Nym exit gateway (a nym-node in exit-gateway mode, bonded into the Nym directory) as part of the infrastructure, with a firm design rule:
- The wallet treats the Floonet exit as a preferred anchor: it is tried first, once per selection cycle, because it is known-good and monitored.
- If the anchor is down or slow, the wallet falls back to public auto-select immediately and retries the anchor on the next reselect.
- Pin-only is forbidden. A wallet must never be configured to use only one exit. Pinning a single exit recreates the single point of failure the design exists to kill, and it would concentrate all exit traffic at one party.
This gives reliability without centralization: the anchor makes the common case fast and dependable, the pool keeps the network alive when the anchor is not.
What the exit sees
Even the operator of the Floonet exit sees only what any public exit operator sees: opaque ciphertext (traffic is TLS end-to-end inside the tunnel, wrapping NIP-44 gift wrap underneath) plus destination IPs. DNS rides DoT and DoH through the tunnel, so not even lookups are visible.
One binding operational rule: run the exit on infrastructure separate from the relay hosts. If the same operator vantage point hosts both the exit and the relay, exit-side timing could be lined up with relay-side arrivals. Separation keeps the two vantage points honest.
References
- Nym operator docs: https://nym.com/docs/operators/nodes/nym-node/setup.
- Rate limiting under mixnet traffic: Rate limits.
Deploy floonet-strfry
Summary. floonet-strfry is stock strfry at a pinned upstream ref plus a Floonet spec laid on top: the write-policy plugin, the bundled name authority, and a TLS proxy, shipped as one unit. Three deploy paths, easiest first.
1. Docker Compose (recommended)
One command brings up the whole unit: relay, name authority, and reverse proxy with automatic TLS.
git clone <floonet-strfry repo>
cd floonet-strfry
cp .env.example .env # edit: your domain, contact, and (optionally) prices
docker compose up -d
The .env file is the only thing you edit. Containers run non-root with a read-only filesystem except the data volume, and the upstream strfry ref is pinned so you always build a known tree.
Verify it is up:
curl -H 'Accept: application/nostr+json' https://relay.yourdomain/ # NIP-11
curl https://relay.yourdomain/api/v1/health # name authority
2. apply-spec (build strfry yourself, add the Floonet layer)
If you already run strfry or want it on bare metal, deploy/strfry/apply-spec.sh builds stock strfry at the pinned ref and lays the Floonet conf, plugin, and name authority on top:
./deploy/strfry/apply-spec.sh
strfry core stays stock; the spec only adds config and the plugin. This is the “stock + spec” pattern: upgrades track upstream strfry directly.
3. From source
Build strfry per its upstream docs (make setup-golpe && make), then:
- Install
strfry.conffromdeploy/strfry/strfry.conf(see Configuration). - Install the write-policy plugin and point
relay.writePolicy.pluginat it. - Run the bundled name authority (its own small service with its own SQLite; see The bundled name authority).
- Front both with a TLS reverse proxy that forwards
X-Real-IP(see Hardening).
After deploying
- Confirm the whitelist: publish an allowed kind (it persists) and a kind
1note (it is dropped). This is the primary acceptance test. - Check the NIP-11 document reads as a neutral Floonet relay with no payment wording.
- Add the relay to your wallet and send yourself a payment end to end.
Configuration (floonet-strfry)
Summary. Two files matter:
strfry.conf(the relay itself) and the plugin/authority environment (the Floonet policy). The compose deployment reduces both to one.env.
strfry.conf
The Floonet spec ships a strfry.conf with these keys set; everything else is stock strfry.
| Key | Floonet default | Meaning |
|---|---|---|
relay.info.name | Floonet Relay | NIP-11 name; keep it payment-neutral |
relay.info.description | A strfry Floonet relay. | NIP-11 description; same rule |
relay.writePolicy.plugin | path to the Floonet plugin | The policy engine; see The write-policy plugin |
relay.auth.enabled | false | NIP-42; enable for auth-gated modes |
events.maxEventSize | large enough for gift-wrapped slatepacks | Do not shrink; see Gift wraps |
Floonet policy environment
The plugin and the bundled name authority read one shared environment (in compose, the .env file):
| Key | Default | Meaning |
|---|---|---|
ALLOWED_KINDS | 0,3,5,13,1059,10002,10050,27235 | The whitelist; default deny |
FLOONET_PAY_MODE | off | off, name (pay to claim a name), or write (pay to write) |
FLOONET_NAME_PRICE_GRIN | unset | Price of a name in GRIN when FLOONET_PAY_MODE=name |
GOBLINPAY_URL | unset | Your GoblinPay server, required for any paid mode |
GOBLINPAY_TOKEN | unset | GoblinPay API token; keep it out of the repo, mount it 0400 |
The full key table for both packages lives in the config keys reference.
The one-file experience
In the compose deployment, .env.example documents every key above with comments. Turning on paid names is three edits:
FLOONET_PAY_MODE=name
FLOONET_NAME_PRICE_GRIN=5
GOBLINPAY_URL=https://pay.yourdomain
See Charge GRIN for your relay for the walkthrough.
The write-policy plugin
Summary. All Floonet policy in floonet-strfry lives in one small, documented write-policy plugin: kind whitelist first, then auth, then the paid gate, then the name-authority consult. strfry core stays stock. Rejections are fail-closed.
How strfry plugins work
strfry’s intended extension point is the write policy (relay.writePolicy.plugin in strfry.conf; upstream docs in strfry/docs/plugins.md, implementation in src/PluginEventSifter.h). For every incoming event, strfry writes a JSON object to the plugin’s stdin, including the event (with its kind) and, when NIP-42 is enabled, the authed pubkey of the connection. The plugin answers with one of:
accept: store the event.reject: refuse, with a message the client sees.shadowReject: refuse silently; the client thinks it succeeded.
The Floonet plugin
The Floonet plugin is a small program structured as a chain of pluggable checks, each with its own config:
- Kind whitelist (the keystone).
kindmust be inALLOWED_KINDS, or the event is shadow-rejected. This check runs first and cannot be disabled. - Auth, when enabled: require an
authedpubkey, or require membership in a pubkey whitelist. - Paid gate, when
FLOONET_PAY_MODE=write: the authed pubkey must have a confirmed GoblinPay payment on record. Results are cached with a TTL so the plugin does not call GoblinPay on every event. - Name authority consult, for policies that depend on name state.
Fail-closed is the invariant across all checks: malformed input, an unreachable config, or an errored check means reject, never accept.
Extending it
The plugin is meant to be edited by operators:
- Add a kind: edit
ALLOWED_KINDSand restart. No code. - Add a policy: add a check function to the chain; each check receives the event and the
authedpubkey and returns accept, reject, or pass-to-next. - Replace it entirely: point
relay.writePolicy.pluginat your own program; the stdin/stdout contract is all there is.
References
- The whitelist rationale: The whitelist: default deny.
- The paid gate: Paid names via GoblinPay.
- strfry plugin docs: https://github.com/hoytech/strfry/blob/master/docs/plugins.md.
The bundled name authority
Summary. floonet-strfry ships the name authority inside the package: one compose unit contains strfry, the authority, and the proxy. The authority is a lean port of goblin-nip05d, swept down to what strfry deployments actually need, keeping its own small SQLite database.
Why bundled
The name authority is what makes a relay useful to a community: it is where alice comes from. Telling operators to “also go run this other service” is how services never get run. So floonet-strfry ships the authority in the box: the default compose brings it up alongside the relay, already wired to the same domain and proxy.
Shape
- Its own service, its own storage. The authority is a separate small process with its own SQLite database. It is deliberately not bolted into strfry’s LMDB; the relay stores events, the authority stores names, and neither can corrupt the other.
- Swept lean. The code is a port of the goblin-nip05d reference, reduced to what a strfry deployment needs. Everything not load-bearing was removed.
- Consulted by the plugin. The write-policy plugin can consult the authority for policies that depend on name state.
- Same rules as everywhere. Validation, cap 20, one name per key, reserved list, look-alike folding, NIP-98 auth, replay protection, cooldown: see The name authority.
Endpoints
The authority serves the standard set under the relay’s domain, via the shared proxy:
GET /.well-known/nostr.json?name=alice
POST /api/v1/register (NIP-98)
DELETE /api/v1/register/{name} (NIP-98)
GET /api/v1/by-pubkey/{pubkey}
GET /api/v1/profile/{name}
GET /api/v1/name/{name}
GET /api/v1/health
Full request and response shapes are in the endpoints reference.
Paid names
When FLOONET_PAY_MODE=name, the authority requires a confirmed GoblinPay payment before POST /api/v1/register succeeds. See Paid names via GoblinPay.
Backups
Back up one file: the authority’s SQLite database. Every registered name on your domain lives there, and NIP-05 resolution for your users depends on it.
Paid names via GoblinPay
Summary. An operator can charge GRIN for names (or for write access). The price is a config value, payments are handled by a GoblinPay server the operator runs, and the relay’s public metadata stays payment-free throughout.
The model
GoblinPay is the Grin payment backend. The name authority (or the write-policy plugin, for paid writes) talks to it over REST:
- A user asks to register
alice. - With
FLOONET_PAY_MODE=name, the authority quotesFLOONET_NAME_PRICE_GRINand creates a GoblinPay invoice. - The user pays in GRIN: via the GoblinPay pay page, a manual slatepack exchange, or a
grin1address if the operator enabled GoblinPay’s Tor method. - GoblinPay confirms the payment on chain (payment proof included).
- The authority sees the confirmed payment and completes the registration. Results are cached with a TTL, so checks are cheap.
Until step 5, registration is refused. Change the price in config and the quote changes; no code involved.
Modes
FLOONET_PAY_MODE | Behavior |
|---|---|
off | Everything free (default) |
name | Claiming a name requires a confirmed payment of FLOONET_NAME_PRICE_GRIN |
write | Writing events requires a confirmed payment (the plugin enforces it per authed pubkey) |
What the client sees
The name-authority response for an unpaid registration carries enough information for a wallet to generate the payment page automatically: the future wallet claim flow is “type a name server and a name, press claim, get sent to a pay page”. The server side supports that flow today.
The neutrality rule still applies
Charging for names changes nothing about the relay’s public face: NIP-11 metadata stays neutral. Payments are a matter between the operator’s authority and the user’s wallet; the relay itself neither sees nor mentions them.
Beyond names
The paid gate is one mechanism applied to many resources. Names are the first; media storage is the documented second. See Media for GRIN.
Deploy floonet-rs
Summary. floonet-rs is a fork of nostr-rs-relay: one Rust binary containing the relay, the admission policies, the name authority, and the GoblinPay processor. Three deploy paths, easiest first.
1. Installer + systemd (recommended)
No toolchain needed. The installer drops the prebuilt binary, an environment file, and a hardened systemd unit:
curl -fsSL <floonet-rs installer URL> | sh
sudo systemctl enable --now floonet-rs
The unit ships with the full sandbox set (DynamicUser, ProtectSystem=strict, NoNewPrivileges, and friends; see Hardening). Edit /etc/floonet-rs/config.toml and the environment file, then restart.
Verify:
curl -H 'Accept: application/nostr+json' https://relay.yourdomain/ # NIP-11
curl https://relay.yourdomain/api/v1/health # name authority
2. Docker Compose
The repo ships a compose file with the relay and a TLS proxy, mirroring the floonet-strfry unit:
git clone <floonet-rs repo>
cd floonet-rs
cp .env.example .env # edit: domain, contact, prices if any
docker compose up -d
Containers are non-root with a read-only filesystem except the data volume.
3. From source
git clone <floonet-rs repo>
cd floonet-rs
cargo build --release
./target/release/floonet-rs --config config.toml
Front it with a TLS reverse proxy that forwards X-Real-IP (load-bearing for rate limiting), then follow Configuration.
After deploying
- Confirm the whitelist: publish an allowed kind (persists), then a kind
1note (dropped). The primary acceptance test. - Fetch the NIP-11 document and confirm it reads as
floonet-rs-relaywith a neutral description and no payment wording. - Add the relay to your wallet and complete a payment end to end.
Configuration (floonet-rs)
Summary. One
config.toml, inherited from nostr-rs-relay and extended with the Floonet sections. The four Floonet features (whitelist, auth, paid access, name authority) are each a small block of keys.
The blocks that matter
[info]: neutral metadata
[info]
name = "floonet-rs-relay"
description = "A Floonet relay for the Grin community Nostr network."
Keep it payment-neutral. Operators may customize; the defaults carry zero payment language.
[limits]: the whitelist and event size
[limits]
# The keystone: default deny. Only these kinds are accepted.
event_kind_allowlist = [0, 3, 5, 13, 1059, 10002, 10050, 27235]
# Large enough for gift-wrapped slatepacks. Do not shrink.
max_event_bytes = 131072
event_kind_allowlist exists upstream (nostr-rs-relay/src/config.rs:54-79); floonet-rs enforces it in the write path through the admission module.
[authorization]: NIP-42 and whitelists
[authorization]
nip42_auth = false # require AUTH before writes
nip42_dms = false # require AUTH to read gift wraps addressed to you
# pubkey_whitelist = ["<hex>", "<hex>"]
Upstream parsed pubkey_whitelist but did not gate writes on it; floonet-rs enforces it in admission. See Authentication.
[pay_to_relay] + GoblinPay
[pay_to_relay]
enabled = false # flip on for paid modes
processor = "GoblinPay"
With the environment keys FLOONET_PAY_MODE, FLOONET_NAME_PRICE_GRIN, GOBLINPAY_URL, and GOBLINPAY_TOKEN (same names as floonet-strfry; see the config keys reference). The GoblinPay processor implements the upstream PaymentProcessor trait.
[name_authority]
[name_authority]
enabled = true
domain = "relay.yourdomain"
Serves the NIP-05 endpoints in-process. See Name authority.
Environment
Secrets stay out of config.toml: the GoblinPay token and any keys are provided via the systemd environment file (mounted 0400) or container env.
The admission module
Summary. All write policy in floonet-rs flows through one composable admission layer. Each policy is small and independent; the server calls a single entry point; the answer is accept or reject, fail-closed.
Motivation
Upstream nostr-rs-relay makes its accept and reject decisions inline in a large server.rs (the event-acceptance region around server.rs:1324-1361, just before the event is handed to storage). That works for one policy, but Floonet has four (whitelist, auth, paid gate, name authority) and wants operators to add their own. So floonet-rs introduces src/admission.rs: one trait, many small policies, one call site.
The shape
#![allow(unused)]
fn main() {
pub trait EventAdmissionPolicy {
fn check(&self, event: &Event, authed_pubkey: Option<&Pubkey>, repo: &dyn Repo)
-> Decision; // Accept | Reject(reason) | ShadowReject
}
}
The configured policies run in order; the first rejection wins:
- Kind whitelist (
event_kind_allowlist): the keystone, always first, cannot be disabled. - Auth policy: NIP-42 requirement and
pubkey_whitelistmembership, when enabled. - Paid gate: confirmed GoblinPay payment for the authed pubkey, when
FLOONET_PAY_MODE=write. - Name authority policy: checks that depend on name state.
server.rs calls exactly one function; every policy decision, log line, and metric hangs off that one seam.
Fail-closed
A policy that errors (database unreachable, GoblinPay timeout, malformed event) returns a rejection, never an accept. A relay that cannot evaluate its policy does not guess.
Adding a policy
Implement EventAdmissionPolicy, register it in the admission chain, add its config block. The composition is ordinary Rust; there is no plugin ABI to fight. For out-of-process extension, upstream’s gRPC nauthz authorization hook remains available and can be exposed alongside the built-in chain.
References
- The whitelist: The whitelist: default deny.
- The paid gate: The GoblinPay processor.
- Upstream write path:
nostr-rs-relay/src/server.rs:1324-1361.
Name authority (floonet-rs)
Summary. floonet-rs builds the name authority in as a module: the same endpoints, the same rules as the bundled strfry authority, served in-process from the relay binary, with its tables in the relay database via a normal migration.
Shape
- A module, not a sidecar.
src/name_authority.rsserves the NIP-05 and registration endpoints from the same binary and the same port as the relay’s HTTP surface. One process to run, one unit to monitor. (Operators who prefer a separate authority process can run one as a sibling instead; both arrangements are supported.) - Storage via migration. The authority’s tables (
name_claims, andpaid_pubkeysfor the paid gate) are added with a standard repo migration and aDB_VERSIONbump, so they upgrade and back up with the rest of the relay database. - Same rules as everywhere. Validation (lowercase
[a-z0-9._-], alphanumeric ends, cap 20), one active name per key via a partial unique index, reserved list, look-alike folding, NIP-98 registration with replay protection, cooldown. See The name authority.
Endpoints
GET /.well-known/nostr.json?name=alice
POST /api/v1/register (NIP-98)
DELETE /api/v1/register/{name} (NIP-98)
GET /api/v1/by-pubkey/{pubkey}
GET /api/v1/profile/{name}
GET /api/v1/name/{name}
GET /api/v1/health
Shapes in the endpoints reference.
Paid names
With FLOONET_PAY_MODE=name, registration is gated on a confirmed GoblinPay payment of FLOONET_NAME_PRICE_GRIN, using the GoblinPay processor and the invoice tables. The quote endpoint responds with enough for a wallet to generate a pay page automatically.
Config
[name_authority]
enabled = true
domain = "relay.yourdomain"
# reserved = ["admin", "root", ...] # extends the built-in list
# cooldown_seconds = 86400
The GoblinPay processor
Summary. nostr-rs-relay already has a pay-to-relay framework with a
PaymentProcessortrait andaccountandinvoicetables, built for Lightning. floonet-rs adds a GoblinPay implementation of the same trait, so relays can be paid in GRIN instead.
What upstream provides
Upstream’s pay_to_relay model (nostr-rs-relay/src/payment/mod.rs) defines a PaymentProcessor trait with LNBits and CLN implementations, plus account and invoice tables tracking who has paid. floonet-rs keeps that skeleton and swaps the money.
What the GoblinPay processor does
The src/payment/grinpay.rs implementation covers the trait’s lifecycle against a GoblinPay server:
- Create invoice. Ask GoblinPay for an invoice for the configured amount (
FLOONET_NAME_PRICE_GRINfor names, or the write-access price). GoblinPay returns the payment details the client needs, including the pay-page URL. - Confirm. Poll GoblinPay’s REST API for payment status. Confirmation is on-chain, with a Grin payment proof, so a confirmed invoice means real money moved.
- Record. Mark the invoice paid; the admission module’s paid gate and the name authority’s registration path both read that record. Lookups are cached with a TTL.
Enforcement points
- Paid writes (
FLOONET_PAY_MODE=write): the admission chain rejects events from pubkeys without a confirmed payment. - Paid names (
FLOONET_PAY_MODE=name):POST /api/v1/registeris refused until the quoted invoice confirms. Names get a dedicatedname_claimstable rather than overloadingaccount, while reusing the upstreaminvoicetable for the money side.
Payment UX
Users can pay a GoblinPay invoice three ways, depending on what the operator enabled in GoblinPay: the generated pay page, a manual slatepack exchange, or a grin1 address (Tor method). The relay does not care which; it only ever asks GoblinPay “is this invoice confirmed”.
Config
[pay_to_relay]
enabled = true
processor = "GoblinPay"
Plus GOBLINPAY_URL and GOBLINPAY_TOKEN in the environment. Full table in the config keys reference.
Hardening
Summary. Floonet relays ship hardened by default: fail-closed policy, sandboxed systemd units, non-root read-only containers, a reverse proxy with
X-Real-IP, and no secrets in the repo. These defaults are inherited from the goblin-nip05d deployment and are non-negotiable in the shipped packages.
Fail-closed everywhere
Every policy surface treats errors as rejection: a malformed event, an unreadable config, an unreachable database or GoblinPay server all produce a reject, never an accept. A relay that cannot evaluate its policy does not guess.
systemd sandboxing
The shipped units (installer path for floonet-rs; available for bare-metal strfry) carry the full sandbox set:
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
NoNewPrivileges=yes
MemoryDenyWriteExecute=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallFilter=@system-service
CapabilityBoundingSet=
The process owns nothing but its state directory. A compromise of the relay process is a compromise of a throwaway user with a read-only view of the system.
Containers
The compose deployments run every service non-root, with a read-only filesystem except the data volume, and build upstream at a pinned ref (the “stock + spec” pattern), so what you run is a known tree, not whatever upstream’s default branch says today.
The reverse proxy
Both packages expect a TLS-terminating reverse proxy (Caddy and nginx examples ship in each repo) in front of the relay and the name authority. The proxy must forward X-Real-IP: it is load-bearing for rate limiting. The compose units include the proxy already wired.
Secrets
No secrets in the repo, ever. The GoblinPay token and any keys arrive via environment files or mounted files with 0400 permissions.
Event size
Keep the maximum event size large enough for gift-wrapped slatepacks (events.maxEventSize in strfry, max_event_bytes in floonet-rs). Shrinking it below the shipped default silently breaks payments; see Gift wraps.
Rate limits
Summary. Floonet rate limiting is designed for mixnet reality: many honest users share a few Nym exit IPs, so naive per-IP limits punish the wrong people. Limit per connection where possible, keep per-IP windows loose, and lean on the whitelist and NIP-98 replay protection to do the real work.
The mixnet caveat first
Wallet traffic arrives through the Nym mixnet, which means a handful of exit-gateway IPs carry many users. Any control keyed only on IP address will eventually rate limit, or ban, an exit that dozens of honest wallets are using. The rules that follow all account for this:
- Relay websockets: limit per connection, not per IP. Event-rate and subscription limits apply to each websocket independently.
- Do not put Floonet services behind IP-reputation banning (fail2ban and friends) without exempting known mixnet exits, or you will ban your own users in bulk.
What actually protects the relay
- The kind whitelist. Most abuse is simply not storable on a Floonet relay; disallowed kinds are dropped before any quota is touched.
- Per-connection event and subscription limits. Both upstreams provide these; the shipped configs set sane values.
- Per-IP HTTP windows on the name authority. Registration and lookup endpoints keep per-IP windows (reads generous, writes tight), fed by
X-Real-IPfrom the proxy, with limits loose enough that a shared mixnet exit does not starve. The proxy forwardingX-Real-IPis load-bearing; without it every request appears to come from the proxy. - NIP-98 replay protection. Registration requests are single-use: the auth event’s timestamp bound and replay window mean a captured request cannot be replayed to burn a name.
- Name-change cooldown. A key that changes its name waits out a cooldown, which caps name-churn abuse at negligible cost to honest users.
Tuning
The shipped defaults are deliberately generous on reads and conservative on writes. If you tighten them, watch for the mixnet signature in your logs first: many distinct pubkeys behind one IP is normal Floonet traffic, not an attack.
Charge GRIN for your relay
Summary. Turning on paid names is editing three config values: your GoblinPay server URL, the pay mode, and the price. No code, no payment processor account, no third party: your users pay you in GRIN directly.
Prerequisites
- A running Floonet relay (either package).
- A running GoblinPay server you operate: the Grin payment backend that creates invoices, watches the chain, and confirms payments with payment proofs.
The three edits
In your .env (compose) or environment file (systemd):
GOBLINPAY_URL=https://pay.yourdomain # your GoblinPay server
FLOONET_PAY_MODE=name # charge for names
FLOONET_NAME_PRICE_GRIN=5 # the price, in GRIN
Restart, and name registration on your authority now quotes 5 GRIN and refuses to complete until GoblinPay confirms the payment on chain. Edit the price any time; the quote follows the config.
The modes
| Mode | What is paid | Good for |
|---|---|---|
off | Nothing | Community relays, default |
name | Claiming a name | The common case: free relay, paid vanity names |
write | Publishing events | Invite-style relays where writing itself is the resource |
What your users experience
A user claiming a name from a wallet gets sent to your GoblinPay pay page (or pays by manual slatepack, or a grin1 address if you enabled the Tor method). Once the payment confirms, the name is theirs: one name per key, standard NIP-05, resolvable everywhere.
A note worth passing to your users: one wallet can hold multiple Nostr identities. If they pay for a name, the name belongs to the npub that registered it; loading the same wallet and switching to that npub keeps the name.
Two rules to keep
- The relay’s public metadata stays payment-free. Charging for names does not change your NIP-11 document; the shipped defaults already comply.
- The GoblinPay token is a secret. Environment or 0400-mounted file, never the repo.
More things to charge for
Names are the first paid resource, not the last. The same gate can charge for media storage; see Media for GRIN.
Media for GRIN (NIP-96 / Blossom)
Summary. The paid gate is one mechanism applied to many resources. Names are the first implementation; media storage is the documented second: an operator charges GRIN for hosting files, over the same GoblinPay flow, via NIP-96 or Blossom.
The pattern
Everything paid in Floonet goes through one small interface over GoblinPay:
PaidResource {
quote() -> price + invoice for this resource
is_paid() -> has a confirmed payment for it
}
name is the first implementation (paid names). blob/media is the designed-for second, so a chat app or community can enable it by config without reworking payments.
The media case
A community running a chat app on a Floonet relay needs somewhere for images and files. The events referencing media are tiny; the bytes themselves need an HTTP host. Two standard shapes exist:
- NIP-96: HTTP file storage with a well-known discovery document and NIP-98-authenticated uploads.
- Blossom: content-addressed blobs, stored and fetched by sha256, advertised with a kind
10063server list.
Either way, the paid flow is identical to names:
- Client asks to upload; the server quotes a price (per upload, per MB, or per month, admin’s choice).
- The server creates a GoblinPay invoice; the user pays in GRIN.
- On confirmed payment, the upload is accepted and served.
Configuration sketch
FLOONET_PAY_MODE=name # names stay paid (or off)
FLOONET_MEDIA_ENABLED=true
FLOONET_MEDIA_PRICE_GRIN_PER_MB=0.1 # admin-set, like the name price
Status
Media-for-GRIN is documented as the extensibility example and kept modular as a later add-on, in line with the Floonet principle of shipping only what is needed now. The PaidResource seam and the GoblinPay flow it needs are already in both packages; enabling it is an add-on module, not a rework.
Config keys
The keys an operator actually touches, across both packages. Package-specific pages: floonet-strfry configuration, floonet-rs configuration.
Shared Floonet keys (environment)
| Key | Default | Meaning |
|---|---|---|
ALLOWED_KINDS | 0,3,5,13,1059,10002,10050,27235 | The whitelist. Default deny; everything not listed is dropped. |
FLOONET_PAY_MODE | off | off, name (pay to claim a name), or write (pay to publish). |
FLOONET_NAME_PRICE_GRIN | unset | Price of a name in GRIN. Required when FLOONET_PAY_MODE=name. |
GOBLINPAY_URL | unset | The operator’s GoblinPay server. Required for any paid mode. |
GOBLINPAY_TOKEN | unset | GoblinPay API token. Secret: environment or 0400 file only. |
floonet-strfry (strfry.conf)
| Key | Floonet default | Meaning |
|---|---|---|
relay.info.name | Floonet Relay | NIP-11 name, payment-neutral. |
relay.info.description | A strfry Floonet relay. | NIP-11 description, same rule. |
relay.writePolicy.plugin | plugin path | The write-policy plugin. |
relay.auth.enabled | false | NIP-42 authentication. |
events.maxEventSize | shipped default | Keep large enough for gift-wrapped slatepacks. |
The read side may also set filterValidation.allowedKinds to mirror the whitelist on subscriptions.
floonet-rs (config.toml)
| Key | Floonet default | Meaning |
|---|---|---|
info.name | floonet-rs-relay | NIP-11 name, payment-neutral. |
info.description | neutral Floonet wording | NIP-11 description, same rule. |
limits.event_kind_allowlist | [0, 3, 5, 13, 1059, 10002, 10050, 27235] | The whitelist, enforced in admission. |
limits.max_event_bytes | shipped default | Keep large enough for gift-wrapped slatepacks. |
authorization.nip42_auth | false | Require AUTH before writes. |
authorization.nip42_dms | false | Require AUTH to read your gift wraps. |
authorization.pubkey_whitelist | unset | Restrict writes to these pubkeys. |
pay_to_relay.enabled | false | Master switch for the GoblinPay processor. |
pay_to_relay.processor | GoblinPay | Payment backend selection. |
name_authority.enabled | true | Serve the name authority in-process. |
name_authority.domain | operator’s domain | The NIP-05 domain names resolve under. |
Endpoints
The relay
| Endpoint | Protocol | Purpose |
|---|---|---|
wss://relay.yourdomain/ | Nostr over websocket | The relay itself |
https://relay.yourdomain/ with Accept: application/nostr+json | HTTP | The NIP-11 information document |
https://relay.yourdomain/ (browser) | HTTP | A neutral Floonet landing page with the Floonet logo |
The name authority
All endpoints are served under the operator’s domain via the shared proxy. (NIP-98) means the request must carry a NIP-98 Authorization event (kind 27235, u + method + payload tags, bounded timestamp, replay-protected).
| Endpoint | Auth | Purpose |
|---|---|---|
GET /.well-known/nostr.json?name={name} | none | NIP-05 resolution: returns {"names": {"{name}": "<pubkey>"}} |
POST /api/v1/register | NIP-98 | Claim a name for the signing key. Refused until payment confirms when FLOONET_PAY_MODE=name; the refusal response carries the quote and invoice so a wallet can generate the pay page. |
DELETE /api/v1/register/{name} | NIP-98 | Release a name (only by its owner) |
GET /api/v1/by-pubkey/{pubkey} | none | Reverse lookup: the name currently held by a key |
GET /api/v1/profile/{name} | none | Profile data for a name |
GET /api/v1/name/{name} | none | Availability: is this name free, reserved, or taken |
GET /api/v1/health | none | Health probe for monitoring |
Example
$ curl 'https://relay.yourdomain/.well-known/nostr.json?name=alice'
{
"names": {
"alice": "7d2f19c0...a4c41a"
}
}
Rules enforced behind the endpoints
Name validation (lowercase [a-z0-9._-], alphanumeric ends, cap 20), one active name per key, reserved list and look-alike folding, replay windows, and the name-change cooldown. Details: The name authority.
Allowed kinds
The shipped default whitelist, in full. Everything not listed is rejected; see The whitelist: default deny.
| Kind | NIP | Name | Why Floonet carries it |
|---|---|---|---|
0 | 01 | Profile metadata | Display names and avatars, so contacts render as people |
3 | 02 | Contact list | Follow and contact lists |
5 | 09 | Deletion request | Lets users retract their own events |
13 | 59 | Seal | The inner encrypted layer of a gift wrap |
1059 | 59 | Gift wrap | The opaque envelope everything private travels in |
10002 | 65 | Relay list | Where a user can be found |
10050 | 17 | DM relay list | Where to deliver a user’s private messages |
27235 | 98 | HTTP auth | Authenticates name-authority registration requests |
Notes
- The canonical list is whatever the Goblin wallet publishes and reads (
goblin/src/nostr/in the wallet source); the table above matches it. - Kind
22242(NIP-42 AUTH) is a connection-level event, not a stored one, so it does not appear in the storage whitelist; it is handled by the auth machinery when authentication is enabled. - Operators upgrading an existing relay: the list may grow, but never narrow it below what live wallets already depend on.
- Kind reference: https://nostrbook.dev/.