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 over Tor, 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 flagship relay, relay.floonet.dev, runs floonet-strfry and is the Goblin wallet’s default money-path relay: wallets reach it over Tor, dialing its ordinary clearnet host through a Tor exit so the wallet’s own IP never touches the payment path. The same relay also hosts the Magick Market marketplace, so it runs the shipped default whitelist unmodified: one relay, two applications.

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 bundled NIP-05 service that maps names to keys, served under the relay’s own subdomain by default so name@relay.yourdomain just works with no separate hostname to run. Chosen in setup, it can run alongside the relay, standalone, or not at all, and it supersedes the older standalone goblin-nip05d.

The relay needs no special transport component. Wallets reach it over Tor; a Floonet relay is just a normal public relay that accepts Tor connections.

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 core of the 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

The shipped default in both packages is this wallet core plus the Magick Market marketplace kinds (listings, orders, receipts) and Nostr Connect login: 24 kinds in total, and exactly the list running in production on relay.floonet.dev; the allowed kinds reference has the full table. Everything else, zaps and bot spam, 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 (or shrink to the wallet core) without code changes. See The whitelist: default deny.

Public notes get one extra gate on top. The two public-note kinds, 1 (text notes) and 30023 (long-form articles), are accepted only from an operator-chosen set of authorized authors, and are closed by default. That is the self-hoster guarantee: running a Floonet relay means no public-note spam, and you decide exactly who can post notes and articles (for example an official news key), while profiles, gift wraps, marketplace events, and DM and relay lists keep flowing for everyone. See Public notes are author-locked.

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 core of the 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

The shipped default in both packages is this wallet core plus the Magick Market marketplace set (1, 7, 14, 16, 17, 1111, 10000, 30000, 30003, 30023, 30078, 30402, 30405, 30406, 31990) and 24133 (Nostr Connect wallet login): 24 kinds in total. See the allowed kinds reference for the full table with the reasoning per kind.

Two of those kinds, 1 (text notes) and 30023 (long-form articles), carry a second gate on top of the whitelist: they are accepted only from an operator-chosen set of authorized authors, and are closed by default. This keeps public-note spam off a payment relay while still letting the operator (or an official news key) post. See Public notes are author-locked.

The list in production: relay.floonet.dev

The flagship relay is a live example of exactly this policy. It runs floonet-strfry with the shipped default list (no override) and serves two applications at once: the Goblin wallet (private payments as gift wraps) and the Magick Market marketplace (listings, orders, receipts). Zap receipts (9735) are deliberately rejected: Lightning is dead in this GRIN-only ecosystem. Seals (13) are on the list for completeness but in practice only ever travel inside 1059 gift wraps.

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

Disallowed kinds are rejected fail-closed: the plugin answers reject with a terse blocked: event kind not accepted by this relay, and any malformed or unparseable input is rejected too, never accepted. A legitimate wallet never publishes disallowed kinds in the first place.

Growing the list

The whitelist is a single config value: FLOONET_ALLOWED_KINDS, a comma-separated list of integers in the plugin environment (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 (floonet-strfry even reloads the plugin on file change, no restart needed). 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 for the Grin community Nostr network.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.

Retention: a payment cannot be deleted out from under you

A gift wrap is often a payment, and the recipient may be offline for hours, so the relay must hold it until they fetch it. Both packages enforce that at admission, so a malformed or malicious publish cannot arrange for a payment to vanish early. On any kind 1059 event, the relay rejects, fail-closed:

  • A NIP-40 expiration tag. It is the only thing that would let the relay auto-delete an event on a timer, so a gift wrap is never allowed to carry one. An accepted payment gift wrap stays until the recipient reads it.
  • A missing or malformed recipient. Exactly one p tag is required, in strict lowercase 32-byte hex. A mixed-case or absent recipient would pass storage but never route to a wallet, so it is refused rather than silently swallowed.
  • Any extra tag. A real gift wrap carries only that one p tag; anything else is refused, so a relay-sized event cannot be padded with junk.

Operators running the published floonet-strfry or floonet-rs configs get these guards automatically; self-hosters inherit them from the repos with nothing to configure.

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.

Goblin wallets authenticate opportunistically and for free: when a relay issues an AUTH challenge, the wallet answers it in the background with no cost and no user prompt. NIP-42 AUTH is a signature, not a payment, so this is unrelated to any paid gate. It means a relay can gate reads to a message’s recipient (matching the authed key against the gift wrap’s p tag) and a Goblin wallet just works, while a relay that never challenges sees no change.

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 bundle 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. The bundled service is selected in the relay’s setup and can run alongside the relay, as a standalone service, or not at all.

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.

Enabling or disabling the bundled name service

The name service ships in the box; whether it runs is the operator’s choice, made once at setup. There are three arrangements:

  • Alongside the relay (the default). floonet-strfry brings the authority up as its own Compose authority service, wired to the relay’s domain; floonet-rs serves it in-process from the relay binary when [name_authority] enabled = true.
  • Standalone. Run the authority on its own: floonet-strfry’s name-authority/ crate builds and runs as an independent binary (with its own SQLite), and floonet-rs operators who want a separate process can run one as a sibling. This is also what the older goblin-nip05d edition was (see below).
  • Not at all. A relay with no name service. In floonet-rs, leave [name_authority] enabled = false (the shipped default) and the relay runs pure event ingest with no NIP-05 surface. In floonet-strfry, drop the authority service from the Compose stack.

First-run wizard. floonet-strfry’s authority binary has an interactive first-run setup: run it by hand with nothing configured, on a terminal, and it prompts for the essentials (names domain, HTTP bind address, data directory, pay mode and price, and whether to enable name transfers), writes a conventional env file, and starts up. It is skipped entirely when FLOONET_DOMAIN is already set or when stdin is not a TTY, so Docker Compose and systemd deploys stay fully headless. See The bundled name authority.

The rules

The rules originate in the standalone goblin-nip05d reference (the older minimal, single-purpose edition, now superseded by this bundled service) 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.

Same subdomain as the relay

A name authority is only useful if name@relay.yourdomain actually resolves, so both packages serve it under the relay’s own subdomain by default rather than a separate hostname. floonet-rs does this structurally: the authority is an in-process module answering on the same binary and port as the relay’s HTTP surface, so there is nothing to configure. floonet-strfry’s authority is its own process, but the shipped Docker Compose/Caddy stack routes NIP-05 and API paths to it on the same FLOONET_DOMAIN as the relay by default; operators splitting the two across separate subdomains can opt back into co-location with a small nginx snippet. See the package pages: floonet-strfry, floonet-rs.

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.

Name transfers (the name marketplace)

The bundled strfry authority can also let one holder sell a name to another: a seller lodges a signed offer, a buyer pays in GRIN and claims it, and the name row is reassigned from the seller’s key to the buyer’s. This is off by default (FLOONET_TRANSFERS, disabled unless the operator turns it on) and, when on, is strictly non-custodial: the authority holds no funds and has zero GoblinPay involvement. It only verifies a seller-signed offer plus an on-chain Grin payment proof through a read-only Grin node foreign API, then moves the name. Keys never move; only which pubkey owns the name changes. See The bundled name authority.

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

Tor: how wallets reach a relay

Summary. Goblin wallets reach every Floonet relay over Tor. Tor has exactly one job here: hide the wallet’s IP and network location from the relay and from anyone watching the network. The wallet embeds a Tor client (arti, compiled straight into the app) and dials each relay’s ordinary clearnet host over a Tor exit, running the usual hostname-validated TLS for wss://. There is nothing to install or configure on the relay: a Floonet relay is a normal public Nostr relay that simply accepts connections arriving from Tor. Everything else a Grin wallet needs to hide is handled by the relay and the Nostr protocol: message content is end-to-end encrypted, the sender is a throwaway one-time key, and send/receive timing is decorrelated by the relay holding each message and releasing it on a short randomized delay.

Why Tor between the wallet and the 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. Tor breaks that link, and that is the one thing we need it for. The relay is the thing a wallet connects to, so it is the single piece of the system that could otherwise see the wallet’s real network identity. With the wallet dialing over Tor, a Floonet relay sees connections arriving from Tor exit nodes, never from a user’s real IP.

That job is narrow on purpose, because the relay and Nostr already cover everything else:

  • Content is end-to-end encrypted (NIP-44 inside NIP-59 gift wraps); nobody but the recipient can read a payment, least of all the relay.
  • The sender is a throwaway one-time key, so the relay never learns who actually sent a message.
  • Timing is shuffled by the relay itself: it holds each incoming gift wrap and releases it to the recipient on a short, randomized delay, so nobody can match “you sent” to “they received.” This timing decorrelation runs on the relay we already operate and fully control.

So no heavier privacy machinery, layered cover traffic or token-metered bandwidth, is needed for what a Grin wallet actually has to hide. Tor does the one narrow job it is perfect for; our own relay does the rest.

What rides Tor, and what does not

Only the wallet’s Nostr and identity traffic rides Tor: relay websockets, NIP-05 name lookups, the price feed, and the relay-pool fetch. The Grin node’s own blockchain traffic is deliberately not routed through Tor. It stays on the clear internet, direct to the node, exactly as an ordinary Grin wallet talks to its node. Who pays whom lives entirely in the gift-wrapped Nostr layer, so the node has nothing to hide, and keeping node sync on the clear keeps it fast.

What this means for a relay operator

Two things, both simple:

  1. Your relay must accept Tor. Tor is the private path, and now a per-wallet setting: wallets updated from an older version keep it on, brand-new wallets choose at setup (off by default), and any user may turn it off and dial relays directly over clearnet (trading network-level anonymity for speed). The wallet’s candidate pool reflects that. Most entries are chosen precisely because they accept Tor exits; a few large public relays that refuse Tor exit connections (for instance relay.damus.io, nos.lol, relay.primal.net) are carried as clearnet-only entries, reachable only by wallets that have Tor turned off. A relay that blocks or throttles Tor exit IPs is simply unreachable to the Goblin users who keep Tor on, which is most of them. If you run a Floonet relay for Grin users, do not put Tor behind an IP block.
  2. You cannot rate limit by IP alone, and you learn nothing from your logs. Tor connections arrive from a handful of shared exit IPs, each carrying many users, so per-IP limits punish the wrong people; control abuse per connection instead. Combined with gift wraps, a relay operator sees only ciphertext arriving from anonymized connections. There is nothing to leak, subpoena, or sell. See Rate limits.

History: the onion experiment

A brief interim design (Goblin build 133) had each relay publish a co-located Tor onion service, so wallets could dial a pinned .onion with no public DNS on the path. Under load the shared onion hop flapped (WebSocket 1006 closes) and stalled payments, so build 134 dropped onion services entirely. Every relay is now reached over a plain Tor exit straight to its clearnet host, and the relay side carries no transport component at all.

The one honest thing a plain-Tor design gives up: resistance to an adversary who can watch the entire internet at once. Tor does not claim that, and neither do we. It is simply not the threat a low-value Grin payments wallet faces.

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 https://github.com/2ro/floonet-strfry.git
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

Reaching the relay over Tor

Nothing to deploy. Wallets reach the relay over Tor by dialing its ordinary clearnet host through a Tor exit, so the compose stack needs no transport component of its own; the relay is simply a normal public endpoint behind Caddy’s TLS. The only requirement is that nothing in front of the relay blocks Tor exit IPs. See Tor: how wallets reach a relay.

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 policy: publish an allowed kind (it persists) and a kind 1 note from an unauthorized key (it is dropped, since public notes are author-locked and closed by default). 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 for the Grin community Nostr network.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
FLOONET_ALLOWED_KINDSthe Goblin + Magick Market set (24 kinds)The whitelist; default deny. .env.example pins the wallet-only core (0,3,5,13,1059,10002,10050,27235) as a conservative starting point
FLOONET_REQUIRE_AUTHfalseReject events unless the connection completed NIP-42 AUTH (also enable relay.auth in strfry.conf)
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
FLOONET_PAID_CACHE_SECS60TTL for the plugin’s cached paid-status lookups
GOBLINPAY_URLunsetYour GoblinPay server, required for any paid mode
GOBLINPAY_TOKENunsetGoblinPay API token; keep it out of the repo, mount it 0400

Reaching the relay over Tor

There is nothing to configure. Wallets reach the relay over Tor by dialing the stack’s ordinary clearnet host through a Tor exit; the Caddy TLS front and the relay behind it stay a normal public endpoint. Just make sure nothing upstream blocks Tor exit IPs. See Tor: how wallets reach a relay.

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. 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 FLOONET_ALLOWED_KINDS (default: the Goblin + Magick Market set), or the event is rejected. This check runs first and cannot be disabled. It applies to every ingest path, including events pulled in via negentropy sync.

  2. Gift-wrap retention and shape (kind 1059 only, always on, no config). A gift wrap carries a payment, so the relay guards its retention and shape at admission rather than trusting the publishing client. Three rules, each rejecting fail-closed:

    • No expiration. A NIP-40 expiration tag is refused. It is the only automatic deletion trigger strfry has (its reaper runs about every 9 seconds), so forbidding it means a payment gift wrap can never be early-deleted out from under a wallet that has not yet fetched it. This is the retention guarantee: an accepted payment gift wrap stays until the recipient reads it.
    • One well-formed recipient. Exactly one p tag is required, and its value must be strict lowercase 32-byte hex (^[0-9a-f]{64}$). A gift wrap with zero, several, or a mixed-case p tag is rejected. The recipient-only read gate matches #p against the authed pubkey as a case-sensitive lowercase string, so an uppercase or mixed-case recipient would otherwise be admitted and then be permanently undeliverable.
    • No extraneous tags. A well-formed NIP-59 gift wrap legitimately carries just that one p tag, so any other tag is refused. This stops a relay-sized (up to 128 KiB) event from smuggling thousands of junk tags in alongside one valid p.

    Every other kind is unaffected by all three.

  3. Public-note lock. The two public-note kinds, 1 (text notes) and 30023 (long-form articles), are accepted only when the event’s author is in FLOONET_AUTHORIZED_AUTHORS (hex or npub, comma-separated). This list is closed by default: with no authors configured, both kinds are rejected for everyone, so a payment relay never fills with public-note spam. Every other kind is unaffected. The author list can also live in a floonet.env KEY=VALUE file next to the plugin (FLOONET_ENV_FILE overrides the path); real environment variables win, and strfry reloads the plugin on mtime change, so touching it after an edit applies new authors with no restart.

  4. Auth, when FLOONET_REQUIRE_AUTH=true: the connection must have completed NIP-42 AUTH (also enable relay.auth in strfry.conf).

  5. Paid gate, when FLOONET_PAY_MODE=write: the authed pubkey must hold a confirmed payment grant, checked against the bundled name authority (FLOONET_AUTHORITY_URL, which talks to GoblinPay). Results are cached for FLOONET_PAID_CACHE_SECS (default 60) so the plugin does not call out on every event.

Fail-closed is the invariant across all checks: malformed input, an unreachable config, or an errored check means reject, never accept. The first rejection wins.

Extending it

The plugin is meant to be edited by operators:

  • Add a kind: edit FLOONET_ALLOWED_KINDS and restart, or just touch the plugin file; strfry reloads it on mtime change. No code.
  • Add a policy: write def check_foo(req, cfg): return None or "reject reason" and append it to CHECKS; each check receives the request (event plus the authed pubkey) and the config, and the first rejection wins.
  • 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 the successor to goblin-nip05d (the older minimal standalone edition), keeping its own small SQLite database. The operator chooses in setup whether to run it alongside the relay, standalone, or not at all.

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.

The standalone goblin-nip05d project is the older minimal, single-purpose edition of this same name service, now superseded by the bundled one here (which carries the ongoing work: paid names, name transfers, co-location on the relay’s domain). Reach for the standalone repo only if you specifically want a name service with no relay.

Running it, or not

The service is optional and the choice is made at setup:

  • Alongside the relay (default). Leave the authority service in the Compose file; docker compose up -d brings up strfry, the authority, and the proxy together on one domain.
  • Standalone. The name-authority/ crate is a plain cargo build binary you can run on its own host, with its own SQLite and its own vhost.
  • Not at all. Remove (or do not start) the authority service and the relay runs pure event ingest with no NIP-05 surface.

First-run setup wizard

Run the authority binary by hand with nothing configured, on an interactive terminal, and it walks a short first-run wizard: it prompts for the names domain, the HTTP bind address, the data directory, the pay mode and price, and whether to enable name transfers, then writes a conventional env file (/etc/floonet-authority.env for a root install, else ./.env) and starts. The wizard is skipped entirely when FLOONET_DOMAIN is already set, or when stdin/stdout is not a TTY, so the Docker Compose and systemd deployments (which inject the environment) never see it and stay headless.

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.
  • The successor to goblin-nip05d. It grew from the goblin-nip05d reference and is now where the name service is maintained.
  • 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.

Co-located on the relay’s domain

FLOONET_AUTHORITY_COLOCATED is on by default: the shipped deploy/Caddyfile routes /.well-known/nostr.json and /api/* to the authority and everything else to the relay, both on the single FLOONET_DOMAIN the compose stack brings up, so name@FLOONET_DOMAIN resolves with nothing to configure. Operators who split the relay and the authority across separate subdomains behind nginx (the deploy/us-east/ pattern: relay on relay.example, the authority’s own vhost on nm.example) can opt back into co-location by including the shipped deploy/us-east/colocated-authority.conf snippet in the relay vhost, ahead of the WebSocket catch-all. That snippet only proxies the exact-match NIP-05 read (GET /.well-known/nostr.json); registration and the rest of /api/* stay on the authority’s own domain. See the README’s “Co-locating names on the relay domain” section for the full nginx include.

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.

Name transfers

The authority can optionally run a non-custodial name marketplace: one holder sells a name to another. It is off by default and mounts no routes unless the operator sets FLOONET_TRANSFERS=true (which also requires FLOONET_GRIN_NODE_URL, a read-only Grin node foreign API used to confirm payment). When on, four routes appear under /api/v1/transfer/:

POST   /api/v1/transfer/offer          (NIP-98)  seller lodges a signed kind-3402 offer
GET    /api/v1/transfer/offer/{id}               public: read an offer and its status
DELETE /api/v1/transfer/offer/{id}     (NIP-98)  seller revokes a live offer
POST   /api/v1/transfer/claim          (NIP-98)  buyer claims the name with a Grin payment proof

A transfer reassigns one name row from the seller’s pubkey to the buyer’s after the authority verifies a seller-signed offer and an on-chain Grin payment proof (default FLOONET_TRANSFER_MIN_CONF=10 confirmations). It is strictly non-custodial with zero GoblinPay involvement: the authority holds no funds, it only checks crypto and a read-only node. Keys never move; only the name’s owning pubkey changes. This is independent of the pay mode: paid names and transfers can each be on or off separately.

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: from Goblin Wallet over Nostr on the pay page, or, if the operator enabled GoblinPay’s optional grin1 rail (off by default), from any Grin wallet over Tor. A wallet that cannot deliver its slatepack automatically can paste it back into the pay page instead.
  4. GoblinPay confirms the payment on chain (payment proof included). “Confirmed” is GoblinPay’s house standard of GP_CONFIRMATIONS on-chain confirmations, default 10.
  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.

The repo ships an installer that drops the binary, the default config, and a hardened systemd unit. Build once, then let it lay everything out:

git clone https://github.com/2ro/floonet-rs.git
cd floonet-rs
cargo build --release
sudo sh deploy/install.sh
sudo systemctl enable --now floonet-rs

The installer is idempotent: re-running it upgrades the binary and unit but never overwrites an existing /etc/floonet-rs/config.toml. Run from an unpacked release archive it needs no toolchain at all; the same script finds the prebuilt binary next to itself.

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 https://github.com/2ro/floonet-rs.git
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 https://github.com/2ro/floonet-rs.git
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 policy: publish an allowed kind (persists), then a kind 1 note from an unauthorized key (dropped, since public notes are author-locked and closed by default). 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.
  • Confirm nothing in front of the relay blocks Tor: wallets reach it over Tor, dialing its clearnet host through a Tor exit.

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. The shipped
# list is the Goblin wallet + Magick Market union; see the allowed kinds
# reference for what each one is.
event_kind_allowlist = [
    0, 1, 3, 5, 7, 13, 14, 16, 17, 1059, 1111, 10000, 10002, 10050, 24133,
    27235, 30000, 30003, 30023, 30078, 30402, 30405, 30406, 31990,
]
# Default 262144: large enough for gift-wrapped slatepacks. Do not shrink.
#max_event_bytes = 262144

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            # enable NIP-42 (the relay sends AUTH challenges)
nip42_dms = false             # send gift wraps only to their authenticated recipients
# require_auth_to_write = false   # with nip42_auth: only AUTHed clients may publish
# pubkey_whitelist = ["<hex>", "<hex>"]

Upstream parsed pubkey_whitelist but did not gate writes on it; floonet-rs enforces it in admission. See Authentication.

[goblinpay]: paid names and paid writes

[goblinpay]
pay_mode = "off"              # off | name | write
#url = "https://pay.example.com"
#api_token = ""               # prefer the FLOONET_GOBLINPAY_TOKEN env var
#name_price_grin = 1.0
#admission_price_grin = 1.0

Every key is also readable from the environment (FLOONET_PAY_MODE, FLOONET_GOBLINPAY_URL, FLOONET_GOBLINPAY_TOKEN, FLOONET_NAME_PRICE_GRIN; see the config keys reference). Setting pay_mode = "write" configures upstream’s [pay_to_relay] section for GoblinPay automatically; you normally never edit that section yourself. The GoblinPay processor implements the upstream PaymentProcessor trait.

[name_authority]

[name_authority]
enabled = true                          # off in the shipped config
domain = "relay.yourdomain"             # the @domain names live under
base_url = "https://relay.yourdomain"   # LOAD-BEARING: NIP-98 auth is verified against it

Serves the NIP-05 endpoints in-process. Name length, change cooldown, rate limits, and a reserved-names file are further keys in the same section. See Name authority.

Reaching the relay over Tor

There is nothing to configure here. Wallets reach the relay over Tor by dialing its ordinary clearnet host through a Tor exit; the relay is a normal public endpoint. Make sure whatever fronts it (TLS proxy, firewall) accepts connections from Tor exit IPs. See Tor: how wallets reach a relay.

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. Gift-wrap retention and shape (kind 1059 only, always on): rejects a NIP-40 expiration tag (so a payment gift wrap can never be reaped before the recipient reads it, the retention guarantee); requires exactly one p recipient tag in strict lowercase 32-byte hex (a mixed-case value would pass the read gate’s lowercase comparison and become undeliverable); and rejects any other tag, so a relay-sized event cannot smuggle junk tags past admission. Every other kind is unaffected.
  3. Public-note lock (authorization.public_note_authors): kinds 1 and 30023 are accepted only from the configured authors (hex or npub); closed by default, so with no authors set both kinds are rejected for everyone. Every other kind is unaffected.
  4. Auth policy: NIP-42 requirement and pubkey_whitelist membership, when enabled.
  5. Paid gate: confirmed GoblinPay payment for the authed pubkey, when FLOONET_PAY_MODE=write.
  6. 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.

Enabled or not

The name service is bundled but optional, switched in setup by one key:

  • [name_authority] enabled = true serves it in-process on the relay’s own listener.
  • enabled = false (the shipped default) runs the relay with no name service at all: pure event ingest, no NIP-05 surface.
  • Operators who prefer a separate authority process can run one as a sibling instead (the same code, standalone). Both arrangements are supported.

Name transfers (the non-custodial name marketplace) currently ship in the bundled strfry authority, not in the floonet-rs module.

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.) Because it is the same listener, name@relay.yourdomain resolves automatically; there is no split-hostname deployment to opt back into, unlike floonet-strfry’s Docker Compose vs. split-nginx choice.
  • 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"             # the @domain names live under
base_url = "https://relay.yourdomain"   # LOAD-BEARING: NIP-98 auth is verified against it
# 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/goblinpay.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. An invoice moves open -> paid (the payment landed) -> confirmed (its kernel reached GoblinPay’s house standard of GP_CONFIRMATIONS on-chain confirmations, default 10); the paid gate and name registration only act on confirmed. The status API exposes both confirmations and confirmations_required.
  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

How a user pays a GoblinPay invoice is up to the operator’s GoblinPay configuration; the relay does not care which path they take, it only ever asks GoblinPay “is this invoice confirmed”.

  • Goblin Wallet (Nostr). The default: the user scans the pay page’s checkout code and the payment auto-receives over Nostr.
  • Optional grin1 / Tor rail. If the operator turns on GoblinPay’s grin1 rail (GP_GRIN1_RAIL, off by default), the pay page also offers a Grin rail so a user can pay from any Grin wallet over Tor, not just Goblin Wallet. Goblin stays the default tab.
  • Manual paste-back. On either rail, a wallet that cannot deliver its slatepack automatically can paste it into the pay page and GoblinPay finishes the exchange server-side.

Config

[goblinpay]
pay_mode = "name"             # or "write"
url = "https://pay.example.com"

Plus FLOONET_GOBLINPAY_TOKEN in the environment. Setting a paid pay_mode wires upstream’s [pay_to_relay] to the GoblinPay processor automatically. Full table in the config keys reference, full walkthrough in Configuration.

GoblinPay: take Grin payments

Summary. GoblinPay is the Grin payment backend behind Floonet’s paid names and paid writes, and it stands on its own as a merchant till: point a WooCommerce or Medusa store at it, or integrate its REST API directly the way Magick Market does. It is receive-only, coins never pass through the API, and the recommended way to stand one up is the built-in setup wizard.

What GoblinPay is

GoblinPay is a small, self-hosted till that receives Grin. A customer’s Goblin wallet pays it as a gift-wrapped slatepack over Nostr (or, for non-Goblin wallets, over the optional grin1/Tor rail), and GoblinPay watches the chain and confirms the payment. Your service is only ever in the authorization path: it asks GoblinPay to create an invoice and reads back the status. No coins ever travel through your application.

An invoice moves through three states:

open ──▶ paid ──▶ confirmed
  • open: created, not yet paid.
  • paid: the payment landed and matched the invoice (in the mempool / low confirmations).
  • confirmed: the paying kernel reached GP_CONFIRMATIONS on-chain confirmations (default 10). Grant the goods on confirmed.

The till has its own seed: run it hot but light, keep only a small working balance on it, and sweep to your main wallet regularly.

The fastest way to stand up a till is the built-in wizard. Install the binary and unit, which also offers to run the wizard for you:

sudo ./deploy/install.sh
# or, if GoblinPay is already installed:
sudo gp-server setup

It asks a few questions, each with a default. It is grin-wallet-faithful about the two things that are yours to own, your wallet password and your seed:

  1. the public URL customers reach this till at,
  2. your shop’s website URL (used to build the webhook URL),
  3. your wallet password: you choose it, entered twice and confirmed to match (hidden input; it is never auto-generated). It encrypts the seed at rest and is not recoverable, so if you forget it you restore from the seed,
  4. the Grin seed: press Enter to generate a fresh 24-word seed, shown once and gated behind an acknowledgement that you wrote it down (exactly like grin-wallet init), or paste your existing recovery phrase,
  5. restart mode: how the till comes back after a reboot (default unattended; see below),
  6. the currencies your shop prices in (default usd),
  7. an advanced yes/no for the grin1/Tor rail (default no).

Everything else it does for you:

  • generates the service secrets (the API token, the admin token, and the webhook secret) so you never invent or type a bearer token; the wallet password is the one secret you choose;
  • creates the encrypted wallet on the spot from the seed, so the seed is consumed once and never lives in the service environment afterwards (it exists only encrypted at rest and in your written backup);
  • probes a curated list of healthy mainnet Grin nodes and picks the first that answers, falling back automatically;
  • writes /etc/goblinpay.env (mode 0640, config plus the bearer tokens) exactly where the shipped gp-server.service looks (EnvironmentFile), and, in unattended mode, /etc/goblinpay/secrets/wallet_password (mode 0400) where its LoadCredential reads it;
  • prints the webhook URL and the three values to paste into a store (GoblinPay URL, API Token, Webhook Secret) plus the private admin token.

Restart mode: unattended (default) or manual

The wizard asks how the till should restart after a reboot; press Enter for the default. Both are honest about their trade-off:

  • Unattended (default). Your chosen password is sealed to this host as a 0400 systemd credential, so the service auto-restarts with no human in the loop. Be clear-eyed about the trade-off: whoever fully controls this machine controls the wallet. Treat the till as a small hot wallet, hold only a working balance, and sweep to your own wallet regularly.
  • Manual. The password lives only in your head; nothing sensitive is written to disk. The wizard drops in a gp-server.service.d/manual.conf that repoints the credential to a tmpfs path (/run/goblinpay/wallet_password), which you populate with systemd-ask-password at each start. /run is tmpfs, so a stolen or powered-off disk holds no wallet key, at the cost of re-entering the password by hand after every reboot.

Re-running is safe: the wizard refuses to overwrite an existing wallet or config unless you pass --reconfigure, which keeps the existing seed and password (the money) untouched and never re-prompts for them, only rewriting the config and tokens. If you run manual restart mode, re-apply it after a reconfigure. Other flags: --prefix DIR (write under a prefix instead of /), --node URL (skip the node probe), --batch (read scripted answers from a non-terminal stdin).

Then start the till:

sudo systemctl start gp-server

The env-var configuration is the advanced path for operators who want to configure GoblinPay by hand; the wizard hides all of it.

The seed, once

GoblinPay is init-once. GP_MNEMONIC (or the seed you paste into the wizard) is used a single time to create the encrypted wallet; after the encrypted seed exists at rest under GP_DATA_DIR (mode 0600), the wallet opens with GP_WALLET_PASSWORD alone. Steady state is “password in, seed out”: if GP_MNEMONIC is still set the server only checks it against the seed at rest and logs a notice to remove it. Prefer file-based delivery (GP_MNEMONIC_FILE, GP_WALLET_PASSWORD_FILE, mode 0400) with systemd LoadCredential or docker /run/secrets so secrets never sit in the environment. You choose GP_WALLET_PASSWORD yourself (the wizard prompts for it twice and confirms the match; it is never auto-generated); how it reaches the service on restart is the restart-mode choice above, sealed to the host for unattended auto-restart or re-entered by hand in manual mode. The Nostr identity (nsec) is a deliberately separate secret from the Grin mnemonic; a random one is generated on first start if unset and persisted NIP-49 encrypted under GP_DATA_DIR/nostr/.

Where to go next

WooCommerce quick start

Summary. Take Grin payments on a WooCommerce store in four steps: run the GoblinPay till on your own server, install the plugin, paste three values, test.

You run the GoblinPay till on your own server, then paste three values into WooCommerce.

1. Install GoblinPay and run the wizard

On your server (a small Linux box is plenty):

sudo ./deploy/install.sh     # builds + installs the binary and unit, then offers the wizard
# or, if GoblinPay is already installed:
sudo gp-server setup

The wizard asks a few questions (all with defaults) and does the rest: you choose your wallet password and write down your seed, and it generates the service tokens, makes your till wallet, picks a healthy Grin node, and writes the config. Two answers matter for WooCommerce:

  • Your till URL: either a subdomain like https://pay.myshop.com, or a path on your existing shop domain like https://myshop.com/pay if you would rather not add a DNS record (see Hosting).
  • Your shop URL: https://myshop.com. The wizard turns this into your webhook URL for you.

When it finishes it prints three values and a webhook URL. Keep that screen. Then start the till:

sudo systemctl start gp-server

2. Install the plugin

Download goblinpay-woocommerce.zip (a GoblinPay release artifact; you can also build it yourself with deploy/package-woocommerce.sh). In WordPress:

Plugins → Add New → Upload Plugin → choose the zip → Install → Activate.

3. Paste the three values

WooCommerce → Settings → Payments → GoblinPay (Grin) → Manage:

FieldPaste
GoblinPay URLyour till URL (e.g. https://pay.myshop.com)
API Tokenthe gp_live_… value from the wizard
Webhook Secretthe whsec_… value from the wizard
Matching modePer-invoice identity (recommended)

Tick Enable Grin payments via GoblinPay and Save changes. The wizard already pointed the till’s webhook at your shop, so nothing else to wire.

4. Test a payment

Place a test order and choose the Grin method at checkout. You are shown a QR (or redirected to the hosted checkout). Pay it from your Goblin wallet: scan, approve, done. The order moves to processing once the payment confirms on chain (default 10 confirmations).

Watch WooCommerce → Status → Logs (source goblinpay) if you enabled debug logging, or the till’s own admin dashboard, to follow the payment.

Notes

  • Refunds are manual: GoblinPay is receive-only, so refund a customer by sending Grin back from your wallet.
  • Run the till hot but light: it has its own seed, so keep only a small working balance on it and sweep to your main wallet regularly.
  • For the full plugin reference see connectors/woocommerce/INSTALL.md in the GoblinPay repo.

Medusa quick start

Summary. Take Grin payments in a Medusa v2 store: run the GoblinPay till, add the GoblinPay payment provider to your Medusa app, point the till’s webhook at the Medusa route.

You run the GoblinPay till on your own server and add the GoblinPay payment provider to your Medusa app.

1. Install GoblinPay and run the wizard

On your server:

sudo ./deploy/install.sh     # installs, then offers the wizard
# or, if already installed:
sudo gp-server setup

The wizard makes your till wallet, generates every secret, picks a healthy Grin node, and writes the config. Note the three values it prints at the end: the till URL, the API Token (gp_live_…), and the Webhook Secret (whsec_…).

One Medusa-specific step. The wizard fills in a WooCommerce webhook URL by default. Medusa uses a different route, so after setup, edit /etc/goblinpay.env and change GP_WEBHOOK_URL to your Medusa route (see step 3), then sudo systemctl restart gp-server. Everything else the wizard generated (tokens, secret, wallet) is reused as-is.

Start the till:

sudo systemctl start gp-server

2. Add the provider to your Medusa app

Copy the connectors/medusa directory into your app (e.g. src/modules/goblinpay), or install it as medusa-payment-goblinpay. Register it in medusa-config.ts under the payment module’s providers with id: "goblinpay", and set its options from the environment:

options: {
  baseUrl: process.env.GOBLINPAY_URL,                  // your till URL
  apiToken: process.env.GOBLINPAY_API_TOKEN,           // the gp_live_… value
  webhookSecret: process.env.GOBLINPAY_WEBHOOK_SECRET, // the whsec_… value
  matchMode: "derived",
}

In your Medusa .env:

GOBLINPAY_URL=https://pay.myshop.com
GOBLINPAY_API_TOKEN=<the gp_live_… value from the wizard>
GOBLINPAY_WEBHOOK_SECRET=<the whsec_… value from the wizard>

Enable the goblinpay provider in the region(s) that should offer Grin (Medusa admin → Settings → Regions → Payment Providers).

3. Point the till’s webhook at Medusa

The Medusa payment webhook route id is <provider id>_<identifier>, both goblinpay, so set on the GoblinPay server (/etc/goblinpay.env):

GP_WEBHOOK_URL=https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay

Then sudo systemctl restart gp-server. The GP_API_TOKEN and GP_WEBHOOK_SECRET the wizard generated must equal the apiToken and webhookSecret you set in Medusa (they already do if you copied them across).

4. Test a payment

Place a test order and choose Grin (GoblinPay). The storefront shows the GoblinPay QR or redirects to the /pay/<token> page. Pay from your Goblin wallet; the order’s payment flips to captured once GoblinPay delivers the webhook. If a delivery is ever missed, the provider falls back to polling GET {baseUrl}/invoice/{invoice_id}.

Notes

  • Refunds are manual (receive-only): send Grin back from your wallet.
  • For the full provider reference see connectors/medusa/INSTALL.md in the GoblinPay repo.

Integrating with the GoblinPay API

Summary. The direct-integration path: your service creates an invoice, the customer pays GoblinPay wallet-to-till, and you grant value once the payment reaches confirmed. This is the same path Magick Market uses, so integrate like Magick.

The important property: no coins ever pass through the API or through your service. Your backend only ever calls create-invoice and reads status. The actual payment is a private, encrypted Grin transfer from the customer’s Goblin wallet to the till over the Nostr gift-wrap rail (or, for non-Goblin wallets, over the optional grin1/Tor rail). Your server is never in the money path; it is only in the authorization path.

your service ──POST /invoice──▶ GoblinPay ──returns nprofile + pay_url──▶ you show the customer
customer's wallet ═══ encrypted Grin payment ═══▶ GoblinPay till   (never touches you)
GoblinPay ──payment.confirmed webhook──▶ your service        (or you poll GET /invoice/{id})
your service grants the goods on "confirmed"

Authentication

Every API call carries a bearer token:

Authorization: Bearer <GP_API_TOKEN>

GP_API_TOKEN is generated for you by gp-server setup (shape gp_live_…). With no token configured the write API is closed (returns 503), never open. A missing or wrong token returns 401.

Create an invoice

POST /invoice
Authorization: Bearer <GP_API_TOKEN>
Content-Type: application/json

Request body (provide either amount_grin or amount_fiat + currency):

FieldTypeNotes
amount_grinintegerExact amount in base units (nanogrin). 1 GRIN = 1_000_000_000 nanogrin.
amount_fiatstringDecimal fiat amount (e.g. "12.50"). Priced to Grin at create time via the rate oracle.
currencystringISO code for amount_fiat (must be in GP_RATE_CURRENCIES).
order_refstringYour order id. Used as the memo/subject match key and echoed back. Optional but recommended.
memostringHuman note shown on the checkout page. Optional.
match_modestringPer-invoice override: memo, derived, or amount. Optional; defaults to GP_MATCH_MODE (derived recommended).

Example:

curl -sS https://pay.myshop.com/invoice \
  -H "Authorization: Bearer $GP_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"amount_grin": 2000000000, "order_ref": "order-1042", "memo": "Order #1042"}'

Response (200) carries invoice_id, token, pay_url, recipient_pubkey, npub, nprofile, qr_svg, amount, status, confirmations, confirmations_required, and your echoed order_ref/memo. To collect payment, either redirect the customer to pay_url (the hosted zero-JS checkout with QR, live status, and manual-paste fallback), or render your own QR from nprofile (a Goblin wallet scans nostr:<nprofile>) or embed the ready-made qr_svg.

The amount field is base units (read this)

Base units and display strings both appear, so be careful:

  • In the request, amount_grin is base units (nanogrin), an integer.
  • In the response, amount is a human display string (e.g. "2 GRIN", or "12.50 usd (~150 GRIN)" for a fiat invoice). Display only; do not parse it for accounting.
  • In the webhook, payment.amount is base units (nanogrin) as an integer, and payment.amount_grin is the human decimal string.

When you reconcile, trust the base-unit integers (amount_grin you sent and payment.amount in the webhook), not the display string.

Read invoice status

GET /invoice/{invoice_id}
Authorization: Bearer <GP_API_TOKEN>

Returns the same JSON shape as create, with the current status, confirmations, and confirmations_required. Status advances open ──▶ paid ──▶ confirmed:

  • open: created, not yet paid.
  • paid: received and matched to this invoice (mempool / low confirmations).
  • confirmed: the paying kernel reached confirmations_required (GP_CONFIRMATIONS, default 10). Grant the goods on confirmed.

Polling GET /invoice/{id} server-to-server is a complete integration on its own if you would rather not run a webhook endpoint.

The payment.confirmed webhook

If you set GP_WEBHOOK_URL (and GP_WEBHOOK_SECRET, which the wizard generates), GoblinPay POSTs a signed JSON event to your endpoint on each payment event so you do not have to poll.

{
  "event_id": "5f3c…",
  "event_type": "payment.confirmed",
  "payment": {
    "slate_id": "…",
    "amount": 2000000000,
    "amount_grin": "2",
    "status": "confirmed",
    "confirmations": 10
  },
  "invoice_id": "…",
  "order_ref": "order-1042"
}
  • event_type is payment.received (first seen) or payment.confirmed (reached GP_CONFIRMATIONS). Grant on payment.confirmed.
  • payment.amount is base units (nanogrin); payment.amount_grin is the human decimal string. payment.confirmations is present only on payment.confirmed.
  • invoice_id / order_ref tie the event back to your order (invoice_id may be null for an unmatched payment).

Verifying the signature

Every delivery carries two headers:

X-GoblinPay-Signature: sha256=<hex>
X-GoblinPay-Delivery: <event_id>

<hex> is HMAC-SHA256(GP_WEBHOOK_SECRET, raw_request_body_bytes). Recompute it over the raw bytes you received (do not re-serialize the JSON) and compare in constant time. Reject on mismatch.

Retry and idempotency semantics

  • At-least-once. Deliveries are persisted; a mid-retry crash resumes.
  • Ack with 2xx. Any 2xx marks the delivery done; anything else, or a transport error, reschedules it.
  • Backoff. min(BASE * 2^(attempts-1), 3600s), doubling per failed attempt up to a 1-hour cap.
  • Give up after 12 attempts (MAX_ATTEMPTS).
  • Deduplicate on event_id (also the X-GoblinPay-Delivery header). A retried delivery repeats the same event_id, so make your handler idempotent: if you have already granted this order, just return 2xx.

Refunds

GoblinPay is receive-only: there is no refund API. Refunds are handled out-of-band by sending Grin back from your wallet.

Hosting: subdomain or zero-DNS path prefix

Summary. A GoblinPay till needs one public HTTPS URL. You can give it a subdomain, or, if you would rather not add a DNS record, mount it under a path on a domain you already have. Either way, put a reverse proxy in front; the wizard prints the exact snippet.

GoblinPay binds locally (GP_BIND, default 127.0.0.1:8080) and is reached at whatever public URL you tell it (GP_PUBLIC_URL). That public URL is the one value your store and your customers see, so pick how it is hosted before you run the wizard.

Option A: a subdomain

Point a subdomain at your server and give the till its own host:

https://pay.myshop.com

Add the DNS record, terminate TLS at your reverse proxy (or let GoblinPay’s built-in rustls do it), and forward to the local bind. This is the clean default when you can add a DNS record.

Option B: zero-DNS path prefix

If you do not want to add a DNS record, mount the till under a path on a domain you already serve:

https://myshop.com/pay

GoblinPay supports a path prefix so every route, checkout page, and webhook URL it generates is prefixed correctly. Set the prefix and the public URL, then have your existing reverse proxy forward that path to the local bind. Nothing new in DNS.

The reverse proxy

Whichever option you pick, run a reverse proxy in front (the wizard prints the exact snippet for your setup, and the repo ships a deploy/Caddyfile). The proxy terminates TLS and forwards to GP_BIND. The webhook URL GoblinPay hands your store is built from the public URL, so as long as the public URL is reachable over HTTPS, WooCommerce, Medusa, and direct API callers all resolve it correctly.

Where this comes up

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 Tor reality: both the relay websockets and the name authority’s clearnet HTTP arrive from a handful of shared Tor exit IPs, each carrying many users, 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 Tor caveat first

Wallet traffic arrives over Tor: every wallet dials the relay’s clearnet host through a Tor exit, so both the relay websockets and the name authority’s HTTP lookups arrive from the same handful of shared Tor exit IPs, each of which many honest wallets sit behind. Any control keyed only on IP address will eventually rate limit, or ban, a source that dozens of users share. 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, which is the only thing that means anything when one exit IP fronts many users.
  • Do not put Floonet services behind IP-reputation banning (fail2ban and friends) without exempting Tor exit IPs, 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 Tor exit IP 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 Tor signature in your logs first: many distinct pubkeys behind one shared Tor exit IP, on both the websocket and the HTTP side, 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, where they pay from Goblin Wallet over Nostr. If you enable GoblinPay’s optional grin1 rail (off by default), the page also lets them pay from any Grin wallet over Tor. Either way, a wallet that cannot hand its slatepack back automatically can paste it into the page instead. Once the payment confirms (GoblinPay’s house standard of 10 on-chain confirmations), 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.

floonet-strfry policy environment

Read by the write-policy plugin and the bundled name authority (in compose, the .env file). FLOONET_AUTHORITY_COLOCATED isn’t a literal key; it names the Compose/Caddy stack’s default behavior of serving the authority on the same FLOONET_DOMAIN as the relay, with nothing to set; a split relay/authority-subdomain deploy opts back in via an nginx snippet. See The name authority.

KeyDefaultMeaning
FLOONET_ALLOWED_KINDSthe Goblin + Magick Market set (24 kinds)The whitelist. Default deny; everything not listed is dropped. .env.example pins the wallet-only core.
FLOONET_AUTHORIZED_AUTHORSempty (closed)Comma-separated pubkeys (hex or npub) allowed to publish the author-locked public-note kinds 1 and 30023. Empty means both are rejected for everyone. May also live in a floonet.env file next to the plugin (FLOONET_ENV_FILE); touch the plugin to reload with no restart.
FLOONET_REQUIRE_AUTHfalseReject writes unless the connection completed NIP-42 AUTH.
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_PAID_CACHE_SECS60TTL for the plugin’s cached paid-status lookups.
FLOONET_TRANSFERSfalseTurn on the authority’s non-custodial name marketplace. Off by default; when on, FLOONET_GRIN_NODE_URL is required. Authority only.
FLOONET_GRIN_NODE_URLunsetRead-only Grin node foreign API used to confirm transfer payments. Required when FLOONET_TRANSFERS=true.

floonet-strfry (strfry.conf)

KeyFloonet defaultMeaning
relay.info.nameFloonet RelayNIP-11 name, payment-neutral.
relay.info.descriptionA strfry Floonet relay for the Grin community Nostr network.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.

There is no transport key to set: wallets reach the relay over Tor by dialing its clearnet host through a Tor exit, so the relay needs no onion or special transport component. Just keep Tor exit IPs unblocked.

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_allowlistthe same 24-kind setThe whitelist, enforced in admission.
limits.max_event_bytesshipped defaultKeep large enough for gift-wrapped slatepacks.
authorization.public_note_authorsunset (closed)Pubkeys (hex or npub) allowed to publish the author-locked public-note kinds 1 and 30023. Unset means both are rejected for everyone.
authorization.nip42_authfalseRequire AUTH before writes.
authorization.nip42_dmsfalseRequire AUTH to read your gift wraps.
authorization.pubkey_whitelistunsetRestrict writes to these pubkeys.
goblinpay.pay_modeoffoff, name, or write; drives the GoblinPay processor. Env alias FLOONET_PAY_MODE.
goblinpay.urlunsetYour GoblinPay server. Env alias FLOONET_GOBLINPAY_URL.
goblinpay.api_tokenunsetGoblinPay API token; prefer the FLOONET_GOBLINPAY_TOKEN env var over the file.
goblinpay.name_price_grin1.0Price of a name in GRIN when pay_mode = "name". Env alias FLOONET_NAME_PRICE_GRIN.
goblinpay.admission_price_grin1.0Price of write admission in GRIN when pay_mode = "write".
name_authority.enabledfalseServe the name authority in-process.
name_authority.domainoperator’s domainThe NIP-05 domain names resolve under, normally the relay’s own domain, since the authority is served in-process on the same listener.
name_authority.base_urloperator’s domainLOAD-BEARING: NIP-98 auth events are verified against <base_url><path>; must match the relay’s public URL.

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

Wallets reach every endpoint above over Tor, dialing the relay’s clearnet host through a Tor exit; there is no separate onion address to publish.

The name authority

All endpoints are served under the relay’s own domain by default (co-located, see The name authority): floonet-rs always, since it’s the same listener; floonet-strfry via its Compose/Caddy stack, or via an nginx opt-in for split relay/authority subdomains, in which case only the GET /.well-known/nostr.json read co-locates and the rest of /api/* stays on the authority’s own domain. (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

Name transfers (optional, strfry authority only)

These routes exist only when the bundled strfry authority has transfers turned on (FLOONET_TRANSFERS=true); otherwise they 404. Transfers are off by default and strictly non-custodial. See The bundled name authority.

EndpointAuthPurpose
POST /api/v1/transfer/offerNIP-98 (seller)Lodge a signed kind-3402 sale offer
GET /api/v1/transfer/offer/{id}noneRead an offer and its status
DELETE /api/v1/transfer/offer/{id}NIP-98 (seller)Revoke a live offer
POST /api/v1/transfer/claimNIP-98 (buyer)Claim the name with a Grin payment proof

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. It is identical in both packages (DEFAULT_ALLOWED_KINDS in floonet-strfry/plugin/floonet_writepolicy.py and in floonet-rs/src/admission.rs), and it is exactly what runs in production on the flagship relay.floonet.dev, which serves two applications: the Goblin wallet and the Magick Market marketplace. Everything not listed is rejected; see The whitelist: default deny.

Goblin wallet kinds

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
2413346Nostr ConnectRemote signing (ephemeral); wallet login
24140GoblinAuthorize SessionsEphemeral session channel for the Authorize Sessions feature
2723598HTTP authAuthenticates name-authority registration requests

Magick Market marketplace kinds

The marketplace also reuses 0, 5, 1059, and 10002 above.

KindNIPNameWhy Floonet carries it
101Text noteBug reports, shared listings
725ReactionLikes on listings and posts
14GammaOrder chatPlaintext order messages in the market’s order flow
16GammaOrder statusOrder processing and status updates
17GammaPayment receiptPayment confirmation in the order flow
111122CommentThreaded comments
1000051Mute listMerchant/product blacklist
3000051People setAdmins, editors, featured users
3000351Bookmark setFeatured collections
3002323Long-form articleNews and long-form posts (author-locked, see below)
3007878App-specific dataCart, relay preferences, registries
3040299Product listingClassified listing / product
30405GammaProduct collectionCollections and featured products
30406GammaShipping optionShipping options in the order flow
3199089Handler informationMarketplace instance settings

“Gamma” marks kinds from the Gamma Markets spec the marketplace implements; they have no merged NIP yet.

Public notes are author-locked

Being on the whitelist is necessary but not sufficient for the two public-note kinds. Kind 1 (text notes) and kind 30023 (long-form articles) are accepted only from an operator-chosen set of authors. This is closed by default: with no authors configured, both kinds are rejected for everyone, so random notes cannot be spammed to the relay. Every other kind is unaffected, and kind 0 profiles stay open so wallets can republish them.

Operators list the authorized authors as hex pubkeys or npubs (their choice):

  • floonet-strfry: FLOONET_AUTHORIZED_AUTHORS, comma-separated, in .env (or in a KEY=VALUE file named floonet.env next to the plugin, path overridable via FLOONET_ENV_FILE; real environment variables win, and touching the plugin reloads it with no restart).
  • floonet-rs: authorization.public_note_authors in config.toml.

Invalid entries are logged and skipped; the rest still apply.

Excluded on purpose

KindWhy it is rejected
9735Lightning zap receipts; dead in this GRIN-only ecosystem
25910ContextVM; only ever rides inside a 1059 gift wrap, never raw
30017/30018Legacy NIP-15 market events; read from sellers’ own relays during migration, never written here

Notes

  • The canonical list is the union of what the two live applications publish and read; the tables above match DEFAULT_ALLOWED_KINDS in both packages and the policy running on relay.floonet.dev.
  • 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/.