Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

PackageBaseShape
floonet-strfrystrfry (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-rsnostr-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:

  1. An event-kind whitelist (the keystone: default deny, see below).
  2. Authentication: NIP-42 plus pubkey whitelists.
  3. Paid access and paid names via GoblinPay (Grin).
  4. 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:

KindWhat it is
0Profile metadata
3Contact list
5Deletion request (NIP-09)
13Seal (NIP-59)
1059Gift wrap (NIP-59): the sealed envelope payments travel in
10002Relay list (NIP-65)
10050DM relay list (NIP-17)
27235HTTP 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

  1. Concepts: the ideas every operator should know, whichever package they run.
  2. floonet-strfry and floonet-rs: deploy, configure, and extend each package.
  3. Operate: hardening, rate limits, and charging GRIN for relay resources.
  4. 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/):

KindNIPWhy a Floonet relay carries it
001Profiles: display names and avatars for contacts
302Contact lists
509Deletion requests, so users can retract events
1359Seals: the inner layer of a gift wrap
105959Gift wraps: the opaque envelopes everything private travels in
1000265Relay lists: where a user can be found
1005017DM relay lists: where to deliver private messages
2723598HTTP 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.conf key relay.writePolicy.plugin; see strfry/docs/plugins.md and src/PluginEventSifter.h upstream). The plugin checks kind against 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 set filterValidation.allowedKinds so disallowed kinds cannot even be subscribed to.
  • floonet-rs uses the upstream event_kind_allowlist limit (upstream nostr-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

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

PackageNIP-11 nameNIP-11 descriptionWhere it is set
floonet-strfryFloonet RelayA strfry Floonet relay.relay.info.name / relay.info.description in strfry.conf
floonet-rsfloonet-rs-relayA 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 name and description.
  • 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

Gift wraps: what a relay sees

Summary. Everything private on Floonet travels as a NIP-59 gift wrap: a kind 1059 event encrypted with NIP-44 to a throwaway key, containing a kind 13 seal, 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

  1. 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.
  2. 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.
  3. 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 a p tag. 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 1059 event, signed by a key that will never be used again.
  • A p tag naming the recipient key (which wallets rotate independently of their funds).
  • Ciphertext of unknowable content.
  • A created_at that 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.maxEventSize in strfry.conf.
  • floonet-rs: max_event_bytes in config.toml.

Do not tighten these below the shipped defaults or wrapped payloads will bounce.

References

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:

  1. The relay sends ["AUTH", "<challenge>"].
  2. The client answers with a kind 22242 event carrying two tags: relay (the relay’s URL) and challenge (the string from step 1), signed by the client’s key.
  3. The relay validates the signature, the challenge, the relay URL, and that created_at is 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.enabled and relay.auth.serviceUrl in strfry.conf; the kind 22242 challenge validation lives in upstream RelayIngester.cpp). The write-policy plugin receives the authed pubkey 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 the Authorization { pubkey_whitelist, nip42_auth, nip42_dms } config in config.rs:81-87). floonet-rs enforces pubkey_whitelist in the admission module, which the upstream parsed but did not gate writes on.

Modes

ModeEffect
off (default)Anyone may read and write, subject to the kind whitelist
require auth to writeUnauthenticated publishes are rejected with auth-required:
require auth to readSubscriptions require a completed AUTH first
whitelist onlyOnly the configured pubkeys may write, authenticated via NIP-42

All modes keep the kind whitelist in force; auth never bypasses it.

References

The name authority

Summary. A name authority maps human names to Nostr keys via NIP-05, so people pay alice instead 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 a1ice cannot impersonate alice).
  • Authenticated registration. Claiming or releasing a name requires a NIP-98 HTTP auth event (kind 27235 with u, method, and payload tags), 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

EndpointPurpose
GET /.well-known/nostr.json?name=NIP-05 resolution
POST /api/v1/registerClaim 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/healthHealth 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

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:

  1. 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.
  2. 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

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.

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:

  1. Install strfry.conf from deploy/strfry/strfry.conf (see Configuration).
  2. Install the write-policy plugin and point relay.writePolicy.plugin at it.
  3. Run the bundled name authority (its own small service with its own SQLite; see The bundled name authority).
  4. 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 1 note (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.

KeyFloonet defaultMeaning
relay.info.nameFloonet RelayNIP-11 name; keep it payment-neutral
relay.info.descriptionA strfry Floonet relay.NIP-11 description; same rule
relay.writePolicy.pluginpath to the Floonet pluginThe policy engine; see The write-policy plugin
relay.auth.enabledfalseNIP-42; enable for auth-gated modes
events.maxEventSizelarge enough for gift-wrapped slatepacksDo not shrink; see Gift wraps

Floonet policy environment

The plugin and the bundled name authority read one shared environment (in compose, the .env file):

KeyDefaultMeaning
ALLOWED_KINDS0,3,5,13,1059,10002,10050,27235The whitelist; default deny
FLOONET_PAY_MODEoffoff, name (pay to claim a name), or write (pay to write)
FLOONET_NAME_PRICE_GRINunsetPrice of a name in GRIN when FLOONET_PAY_MODE=name
GOBLINPAY_URLunsetYour GoblinPay server, required for any paid mode
GOBLINPAY_TOKENunsetGoblinPay 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:

  1. Kind whitelist (the keystone). kind must be in ALLOWED_KINDS, or the event is shadow-rejected. This check runs first and cannot be disabled.
  2. Auth, when enabled: require an authed pubkey, or require membership in a pubkey whitelist.
  3. 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.
  4. 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_KINDS and restart. No code.
  • Add a policy: add a check function to the chain; each check receives the event and the authed pubkey and returns accept, reject, or pass-to-next.
  • Replace it entirely: point relay.writePolicy.plugin at your own program; the stdin/stdout contract is all there is.

References

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.

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:

  1. A user asks to register alice.
  2. With FLOONET_PAY_MODE=name, the authority quotes FLOONET_NAME_PRICE_GRIN and creates a GoblinPay invoice.
  3. The user pays in GRIN: via the GoblinPay pay page, a manual slatepack exchange, or a grin1 address if the operator enabled GoblinPay’s Tor method.
  4. GoblinPay confirms the payment on chain (payment proof included).
  5. 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_MODEBehavior
offEverything free (default)
nameClaiming a name requires a confirmed payment of FLOONET_NAME_PRICE_GRIN
writeWriting 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.

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 1 note (dropped). The primary acceptance test.
  • Fetch the NIP-11 document and confirm it reads as floonet-rs-relay with 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:

  1. Kind whitelist (event_kind_allowlist): the keystone, always first, cannot be disabled.
  2. Auth policy: NIP-42 requirement and pubkey_whitelist membership, when enabled.
  3. Paid gate: confirmed GoblinPay payment for the authed pubkey, when FLOONET_PAY_MODE=write.
  4. 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

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.rs serves 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, and paid_pubkeys for the paid gate) are added with a standard repo migration and a DB_VERSION bump, 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.

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 PaymentProcessor trait and account and invoice tables, 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:

  1. Create invoice. Ask GoblinPay for an invoice for the configured amount (FLOONET_NAME_PRICE_GRIN for names, or the write-access price). GoblinPay returns the payment details the client needs, including the pay-page URL.
  2. 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.
  3. 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/register is refused until the quoted invoice confirms. Names get a dedicated name_claims table rather than overloading account, while reusing the upstream invoice table 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

  1. The kind whitelist. Most abuse is simply not storable on a Floonet relay; disallowed kinds are dropped before any quota is touched.
  2. Per-connection event and subscription limits. Both upstreams provide these; the shipped configs set sane values.
  3. Per-IP HTTP windows on the name authority. Registration and lookup endpoints keep per-IP windows (reads generous, writes tight), fed by X-Real-IP from the proxy, with limits loose enough that a shared mixnet exit does not starve. The proxy forwarding X-Real-IP is load-bearing; without it every request appears to come from the proxy.
  4. 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.
  5. 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

ModeWhat is paidGood for
offNothingCommunity relays, default
nameClaiming a nameThe common case: free relay, paid vanity names
writePublishing eventsInvite-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

  1. The relay’s public metadata stays payment-free. Charging for names does not change your NIP-11 document; the shipped defaults already comply.
  2. 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 10063 server list.

Either way, the paid flow is identical to names:

  1. Client asks to upload; the server quotes a price (per upload, per MB, or per month, admin’s choice).
  2. The server creates a GoblinPay invoice; the user pays in GRIN.
  3. 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)

KeyDefaultMeaning
ALLOWED_KINDS0,3,5,13,1059,10002,10050,27235The whitelist. Default deny; everything not listed is dropped.
FLOONET_PAY_MODEoffoff, name (pay to claim a name), or write (pay to publish).
FLOONET_NAME_PRICE_GRINunsetPrice of a name in GRIN. Required when FLOONET_PAY_MODE=name.
GOBLINPAY_URLunsetThe operator’s GoblinPay server. Required for any paid mode.
GOBLINPAY_TOKENunsetGoblinPay API token. Secret: environment or 0400 file only.

floonet-strfry (strfry.conf)

KeyFloonet defaultMeaning
relay.info.nameFloonet RelayNIP-11 name, payment-neutral.
relay.info.descriptionA strfry Floonet relay.NIP-11 description, same rule.
relay.writePolicy.pluginplugin pathThe write-policy plugin.
relay.auth.enabledfalseNIP-42 authentication.
events.maxEventSizeshipped defaultKeep large enough for gift-wrapped slatepacks.

The read side may also set filterValidation.allowedKinds to mirror the whitelist on subscriptions.

floonet-rs (config.toml)

KeyFloonet defaultMeaning
info.namefloonet-rs-relayNIP-11 name, payment-neutral.
info.descriptionneutral Floonet wordingNIP-11 description, same rule.
limits.event_kind_allowlist[0, 3, 5, 13, 1059, 10002, 10050, 27235]The whitelist, enforced in admission.
limits.max_event_bytesshipped defaultKeep large enough for gift-wrapped slatepacks.
authorization.nip42_authfalseRequire AUTH before writes.
authorization.nip42_dmsfalseRequire AUTH to read your gift wraps.
authorization.pubkey_whitelistunsetRestrict writes to these pubkeys.
pay_to_relay.enabledfalseMaster switch for the GoblinPay processor.
pay_to_relay.processorGoblinPayPayment backend selection.
name_authority.enabledtrueServe the name authority in-process.
name_authority.domainoperator’s domainThe NIP-05 domain names resolve under.

Endpoints

The relay

EndpointProtocolPurpose
wss://relay.yourdomain/Nostr over websocketThe relay itself
https://relay.yourdomain/ with Accept: application/nostr+jsonHTTPThe NIP-11 information document
https://relay.yourdomain/ (browser)HTTPA 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).

EndpointAuthPurpose
GET /.well-known/nostr.json?name={name}noneNIP-05 resolution: returns {"names": {"{name}": "<pubkey>"}}
POST /api/v1/registerNIP-98Claim 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-98Release a name (only by its owner)
GET /api/v1/by-pubkey/{pubkey}noneReverse lookup: the name currently held by a key
GET /api/v1/profile/{name}noneProfile data for a name
GET /api/v1/name/{name}noneAvailability: is this name free, reserved, or taken
GET /api/v1/healthnoneHealth 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.

KindNIPNameWhy Floonet carries it
001Profile metadataDisplay names and avatars, so contacts render as people
302Contact listFollow and contact lists
509Deletion requestLets users retract their own events
1359SealThe inner encrypted layer of a gift wrap
105959Gift wrapThe opaque envelope everything private travels in
1000265Relay listWhere a user can be found
1005017DM relay listWhere to deliver a user’s private messages
2723598HTTP authAuthenticates 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/.