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.
| Package | Base | Shape |
|---|---|---|
| floonet-strfry | strfry (C++) | Stock strfry at a pinned ref plus a spec: a modular write-policy plugin, a bundled name authority, and a TLS proxy, deployed as one Docker Compose unit. |
| floonet-rs | nostr-rs-relay (Rust) | A single binary with an installer and a hardened systemd unit. Policy lives in a composable admission module; the name authority and the GoblinPay payment processor are built in. |
Both add the same four features, each configurable, optional, and modular:
- An event-kind whitelist (the keystone: default deny, see below).
- Authentication: NIP-42 plus pubkey whitelists.
- Paid access and paid names via GoblinPay (Grin).
- A name authority: the bundled NIP-05 service that maps names to keys, served under the relay’s own subdomain by default so
name@relay.yourdomainjust 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:
| Kind | What it is |
|---|---|
0 | Profile metadata |
3 | Contact list |
5 | Deletion request (NIP-09) |
13 | Seal (NIP-59) |
1059 | Gift wrap (NIP-59): the sealed envelope payments travel in |
10002 | Relay list (NIP-65) |
10050 | DM relay list (NIP-17) |
27235 | HTTP auth (NIP-98): used by the name authority |
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
- Concepts: the ideas every operator should know, whichever package they run.
- floonet-strfry and floonet-rs: deploy, configure, and extend each package.
- Operate: hardening, rate limits, and charging GRIN for relay resources.
- Reference: config keys, endpoints, and the allowed-kinds table.
Where these docs cite code, they use file:line references into the package source or the pinned upstream so you can read along.
The whitelist: default deny
Summary. A Floonet relay is default-deny: it accepts only the event kinds on its allow-list and drops everything else. The list is one editable config value in both packages, enforced fail-closed in the write path.
Motivation
A general-purpose Nostr relay stores whatever anyone throws at it: notes, reactions, media metadata, bot spam. A payment relay does not need any of that, and storing it makes the relay bigger, slower, and a more attractive target. Floonet inverts the default: nothing is accepted unless it is explicitly allowed. Allow only what is needed now; expand later by editing config, not code.
The allowed set
The 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/):
| Kind | NIP | Why a Floonet relay carries it |
|---|---|---|
0 | 01 | Profiles: display names and avatars for contacts |
3 | 02 | Contact lists |
5 | 09 | Deletion requests, so users can retract events |
13 | 59 | Seals: the inner layer of a gift wrap |
1059 | 59 | Gift wraps: the opaque envelopes everything private travels in |
10002 | 65 | Relay lists: where a user can be found |
10050 | 17 | DM relay lists: where to deliver private messages |
27235 | 98 | HTTP auth events, used to register names with the name authority |
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.confkeyrelay.writePolicy.plugin; seestrfry/docs/plugins.mdandsrc/PluginEventSifter.hupstream). The plugin checkskindagainst the allow-list first and rejects everything else, fail-closed: if the plugin cannot parse the event or reach its config, the answer is reject. The read side can additionally setfilterValidation.allowedKindsso disallowed kinds cannot even be subscribed to. - floonet-rs uses the upstream
event_kind_allowlistlimit (upstreamnostr-rs-relay/src/config.rs:54-79,config.toml:151-159) and enforces it in the write path inside the admission module, before the event reaches storage. Wrapping the check in the admission layer means it composes cleanly with auth and paid checks.
Behavior on rejection
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
- Enforcement: The write-policy plugin, The admission module.
- The full table: Allowed kinds.
- Kind and NIP details: https://nostrbook.dev/.
Neutral relay metadata (NIP-11)
Summary. A relay’s public NIP-11 information document (name, description, supported NIPs, software) never mentions payments, transactions, slatepacks, or money. Floonet relays only ever see opaque ciphertext, so payment wording would be both inaccurate and a liability. The shipped defaults are neutral Floonet branding.
Motivation
NIP-11 is the JSON document a relay serves when an HTTP client asks for application/nostr+json. It is the relay’s public face: crawlers index it, relay browsers display it, and anyone can fetch it.
A Floonet relay stores gift wraps (kind 1059), which are opaque, encrypted envelopes. The relay cannot know what is inside them. Describing the relay as handling “payments” would therefore be a claim it cannot verify about content it cannot read, and it would paint a target on the operator. So the rule is simple: the relay’s own metadata says nothing about payments.
Shipped defaults
| Package | NIP-11 name | NIP-11 description | Where it is set |
|---|---|---|---|
| floonet-strfry | Floonet Relay | A strfry Floonet relay for the Grin community Nostr network. | relay.info.name / relay.info.description in strfry.conf |
| floonet-rs | floonet-rs-relay | A Floonet relay for the Grin community Nostr network. | the [info] block in config.toml |
Operators can customize both freely. The point is that the defaults carry zero payment language, so nobody ships a payment-labelled relay by accident.
The audit rule
The neutrality rule covers every relay-facing surface, not just NIP-11:
- The NIP-11
nameanddescription. - The HTML landing page the relay serves to browsers (both packages serve a neutral Floonet page with the Floonet logo).
- Any other served JSON.
- The example configs in the READMEs.
Operator documentation (like this book) may of course explain paid names and paid access. The relay’s own public metadata may not.
References
- NIP-11: https://nips.nostr.com/11.
- What the relay actually sees: Gift wraps.
- Charging for resources without advertising it on the relay: Charge GRIN for your relay.
Gift wraps: what a relay sees
Summary. Everything private on Floonet travels as a NIP-59 gift wrap: a kind
1059event encrypted with NIP-44 to a throwaway key, containing a kind13seal, containing the real message (NIP-17). A relay sees ciphertext, a random-looking recipient key, and a deliberately fuzzed timestamp. Nothing else.
The three layers
- The rumor. The actual message (for a wallet, a Grin slatepack riding a NIP-17 private direct message). It is never signed on its own, so it cannot be leaked and attributed.
- The seal (kind
13). The rumor is encrypted with NIP-44 to the recipient and signed by the real sender. The seal proves authorship to the recipient only. - The gift wrap (kind
1059). The seal is encrypted again, this time signed by a one-time throwaway key, and addressed to the recipient’s key in aptag. This is the only layer a relay ever stores.
What the relay can and cannot learn
A Floonet relay storing a gift wrap sees:
- A kind
1059event, signed by a key that will never be used again. - A
ptag naming the recipient key (which wallets rotate independently of their funds). - Ciphertext of unknowable content.
- A
created_atthat is deliberately wrong: NIP-59 tooling backdates both the seal and the wrap by a random amount up to two days, so the timestamp does not reveal real send time.
It cannot see the sender, the content, the amount, or whether the envelope is a payment, a message, or anything else. This is why NIP-11 metadata stays payment-neutral: the relay genuinely does not know.
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
expirationtag. 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
ptag 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
ptag; 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.maxEventSizeinstrfry.conf. - floonet-rs:
max_event_bytesinconfig.toml.
Do not tighten these below the shipped defaults or wrapped payloads will bounce.
References
- NIP-17 (private DMs): https://nips.nostr.com/17.
- NIP-44 (encryption): https://nips.nostr.com/44.
- NIP-59 (gift wrap, kind 13 and 1059): https://nips.nostr.com/59.
- Kind pages: https://nostrbook.dev/kinds/1059.
Authentication (NIP-42)
Summary. Both packages support NIP-42 client authentication plus a pubkey whitelist. Auth is optional and configurable: run fully open, require auth to write, require auth to read, or restrict to a whitelist. Auth composes with the kind whitelist and the paid gate.
The NIP-42 flow
NIP-42 lets a relay learn, cryptographically, which key is on the other end of a websocket:
- The relay sends
["AUTH", "<challenge>"]. - The client answers with a kind
22242event carrying two tags:relay(the relay’s URL) andchallenge(the string from step 1), signed by the client’s key. - The relay validates the signature, the challenge, the relay URL, and that
created_atis recent (about a 10 minute window).
After that, the connection has an authenticated pubkey attached, and every policy decision can use it.
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.enabledandrelay.auth.serviceUrlinstrfry.conf; the kind22242challenge validation lives in upstreamRelayIngester.cpp). The write-policy plugin receives theauthedpubkey with every event, so auth checks, whitelist checks, and paid checks all live in one place: the plugin. - floonet-rs: NIP-42 is fully implemented upstream (the auth state machine in
nostr-rs-relay/src/conn.rs:18-228, and theAuthorization { pubkey_whitelist, nip42_auth, nip42_dms }config inconfig.rs:81-87). floonet-rs enforcespubkey_whitelistin the admission module, which the upstream parsed but did not gate writes on.
Modes
| Mode | Effect |
|---|---|
| off (default) | Anyone may read and write, subject to the kind whitelist |
| require auth to write | Unauthenticated publishes are rejected with auth-required: |
| require auth to read | Subscriptions require a completed AUTH first |
| whitelist only | Only the configured pubkeys may write, authenticated via NIP-42 |
All modes keep the kind whitelist in force; auth never bypasses it.
References
- NIP-42: https://nips.nostr.com/42.
- Config keys: Config keys reference.
The name authority
Summary. A name authority maps human names to Nostr keys via NIP-05, so people pay
aliceinstead of a 64-character key. Both Floonet packages 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
authorityservice, 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 theauthorityservice 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
a1icecannot impersonatealice). - Authenticated registration. Claiming or releasing a name requires a NIP-98 HTTP auth event (kind
27235withu,method, andpayloadtags), with a timestamp bound and a replay window, so a captured request cannot be replayed. - Cooldown. A key that changes its name must wait out a cooldown before changing it again.
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
| Endpoint | Purpose |
|---|---|
GET /.well-known/nostr.json?name= | NIP-05 resolution |
POST /api/v1/register | Claim a name (NIP-98 auth) |
DELETE /api/v1/register/{name} | Release a name (NIP-98 auth) |
GET /api/v1/by-pubkey/{pubkey} | Reverse lookup: which name does this key hold |
GET /api/v1/profile/{name} | Profile data for a name |
GET /api/v1/name/{name} | Availability check |
GET /api/v1/health | Health probe |
See the endpoints reference.
Free or paid
By default names are free. An operator can instead require a confirmed GoblinPay payment of FLOONET_NAME_PRICE_GRIN before a registration succeeds. The price is plain config; see Charge GRIN for your relay.
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
- NIP-05: https://nips.nostr.com/5.
- Package specifics: bundled authority (strfry), in-process module (rs).
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:
- 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. - 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
- The Tor Project: https://www.torproject.org.
- Rate limiting under Tor traffic: Rate limits.
Deploy floonet-strfry
Summary. floonet-strfry is stock strfry at a pinned upstream ref plus a Floonet spec laid on top: the write-policy plugin, the bundled name authority, and a TLS proxy, shipped as one unit. Three deploy paths, easiest first.
1. Docker Compose (recommended)
One command brings up the whole unit: relay, name authority, and reverse proxy with automatic TLS.
git clone 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:
- Install
strfry.conffromdeploy/strfry/strfry.conf(see Configuration). - Install the write-policy plugin and point
relay.writePolicy.pluginat it. - Run the bundled name authority (its own small service with its own SQLite; see The bundled name authority).
- Front both with a TLS reverse proxy that forwards
X-Real-IP(see Hardening).
After deploying
- Confirm the policy: publish an allowed kind (it persists) and a kind
1note 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.
| Key | Floonet default | Meaning |
|---|---|---|
relay.info.name | Floonet Relay | NIP-11 name; keep it payment-neutral |
relay.info.description | A strfry Floonet relay for the Grin community Nostr network. | NIP-11 description; same rule |
relay.writePolicy.plugin | path to the Floonet plugin | The policy engine; see The write-policy plugin |
relay.auth.enabled | false | NIP-42; enable for auth-gated modes |
events.maxEventSize | large enough for gift-wrapped slatepacks | Do not shrink; see Gift wraps |
Floonet policy environment
The plugin and the bundled name authority read one shared environment (in compose, the .env file):
| Key | Default | Meaning |
|---|---|---|
FLOONET_ALLOWED_KINDS | the 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_AUTH | false | Reject events unless the connection completed NIP-42 AUTH (also enable relay.auth in strfry.conf) |
FLOONET_PAY_MODE | off | off, name (pay to claim a name), or write (pay to write) |
FLOONET_NAME_PRICE_GRIN | unset | Price of a name in GRIN when FLOONET_PAY_MODE=name |
FLOONET_PAID_CACHE_SECS | 60 | TTL for the plugin’s cached paid-status lookups |
GOBLINPAY_URL | unset | Your GoblinPay server, required for any paid mode |
GOBLINPAY_TOKEN | unset | GoblinPay API token; keep it out of the repo, mount it 0400 |
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:
-
Kind whitelist (the keystone).
kindmust be inFLOONET_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. -
Gift-wrap retention and shape (kind
1059only, 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
expirationtag 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
ptag 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-caseptag is rejected. The recipient-only read gate matches#pagainst 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
ptag, 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 validp.
Every other kind is unaffected by all three.
- No expiration. A NIP-40
-
Public-note lock. The two public-note kinds,
1(text notes) and30023(long-form articles), are accepted only when the event’s author is inFLOONET_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 afloonet.envKEY=VALUE file next to the plugin (FLOONET_ENV_FILEoverrides the path); real environment variables win, and strfry reloads the plugin on mtime change, sotouching it after an edit applies new authors with no restart. -
Auth, when
FLOONET_REQUIRE_AUTH=true: the connection must have completed NIP-42 AUTH (also enablerelay.authinstrfry.conf). -
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 forFLOONET_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_KINDSand 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 toCHECKS; each check receives the request (event plus theauthedpubkey) and the config, and the first rejection wins. - Replace it entirely: point
relay.writePolicy.pluginat your own program; the stdin/stdout contract is all there is.
References
- The whitelist rationale: The whitelist: default deny.
- The paid gate: Paid names via GoblinPay.
- strfry plugin docs: https://github.com/hoytech/strfry/blob/master/docs/plugins.md.
The bundled name authority
Summary. floonet-strfry ships the name authority inside the package: one compose unit contains strfry, the authority, and the proxy. The authority is 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
authorityservice in the Compose file;docker compose up -dbrings up strfry, the authority, and the proxy together on one domain. - Standalone. The
name-authority/crate is a plaincargo buildbinary you can run on its own host, with its own SQLite and its own vhost. - Not at all. Remove (or do not start) the
authorityservice 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.
Paid names
When FLOONET_PAY_MODE=name, the authority requires a confirmed GoblinPay payment before POST /api/v1/register succeeds. See Paid names via GoblinPay.
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:
- A user asks to register
alice. - With
FLOONET_PAY_MODE=name, the authority quotesFLOONET_NAME_PRICE_GRINand creates a GoblinPay invoice. - The user pays in GRIN: 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.
- GoblinPay confirms the payment on chain (payment proof included). “Confirmed” is GoblinPay’s house standard of
GP_CONFIRMATIONSon-chain confirmations, default 10. - The authority sees the confirmed payment and completes the registration. Results are cached with a TTL, so checks are cheap.
Until step 5, registration is refused. Change the price in config and the quote changes; no code involved.
Modes
FLOONET_PAY_MODE | Behavior |
|---|---|
off | Everything free (default) |
name | Claiming a name requires a confirmed payment of FLOONET_NAME_PRICE_GRIN |
write | Writing events requires a confirmed payment (the plugin enforces it per authed pubkey) |
What the client sees
The name-authority response for an unpaid registration carries enough information for a wallet to generate the payment page automatically: the future wallet claim flow is “type a name server and a name, press claim, get sent to a pay page”. The server side supports that flow today.
The neutrality rule still applies
Charging for names changes nothing about the relay’s public face: NIP-11 metadata stays neutral. Payments are a matter between the operator’s authority and the user’s wallet; the relay itself neither sees nor mentions them.
Beyond names
The paid gate is one mechanism applied to many resources. Names are the first; media storage is the documented second. See Media for GRIN.
Deploy floonet-rs
Summary. floonet-rs is a fork of nostr-rs-relay: one Rust binary containing the relay, the admission policies, the name authority, and the GoblinPay processor. Three deploy paths, easiest first.
1. Installer + systemd (recommended)
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
1note 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-relaywith 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:
- Kind whitelist (
event_kind_allowlist): the keystone, always first, cannot be disabled. - Gift-wrap retention and shape (kind
1059only, always on): rejects a NIP-40expirationtag (so a payment gift wrap can never be reaped before the recipient reads it, the retention guarantee); requires exactly oneprecipient 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. - Public-note lock (
authorization.public_note_authors): kinds1and30023are 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. - Auth policy: NIP-42 requirement and
pubkey_whitelistmembership, when enabled. - Paid gate: confirmed GoblinPay payment for the authed pubkey, when
FLOONET_PAY_MODE=write. - Name authority policy: checks that depend on name state.
server.rs calls exactly one function; every policy decision, log line, and metric hangs off that one seam.
Fail-closed
A policy that errors (database unreachable, GoblinPay timeout, malformed event) returns a rejection, never an accept. A relay that cannot evaluate its policy does not guess.
Adding a policy
Implement EventAdmissionPolicy, register it in the admission chain, add its config block. The composition is ordinary Rust; there is no plugin ABI to fight. For out-of-process extension, upstream’s gRPC nauthz authorization hook remains available and can be exposed alongside the built-in chain.
References
- The whitelist: The whitelist: default deny.
- The paid gate: The GoblinPay processor.
- Upstream write path:
nostr-rs-relay/src/server.rs:1324-1361.
Name authority (floonet-rs)
Summary. floonet-rs builds the name authority in as a module: the same endpoints, the same rules as the bundled strfry authority, served in-process from the relay binary, with its tables in the relay database via a normal migration.
Enabled or not
The name service is bundled but optional, switched in setup by one key:
[name_authority] enabled = trueserves 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.rsserves the NIP-05 and registration endpoints from the same binary and the same port as the relay’s HTTP surface. One process to run, one unit to monitor. (Operators who prefer a separate authority process can run one as a sibling instead; both arrangements are supported.) Because it is the same listener,name@relay.yourdomainresolves 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, andpaid_pubkeysfor the paid gate) are added with a standard repo migration and aDB_VERSIONbump, so they upgrade and back up with the rest of the relay database. - Same rules as everywhere. Validation (lowercase
[a-z0-9._-], alphanumeric ends, cap 20), one active name per key via a partial unique index, reserved list, look-alike folding, NIP-98 registration with replay protection, cooldown. See The name authority.
Endpoints
GET /.well-known/nostr.json?name=alice
POST /api/v1/register (NIP-98)
DELETE /api/v1/register/{name} (NIP-98)
GET /api/v1/by-pubkey/{pubkey}
GET /api/v1/profile/{name}
GET /api/v1/name/{name}
GET /api/v1/health
Shapes in the endpoints reference.
Paid names
With FLOONET_PAY_MODE=name, registration is gated on a confirmed GoblinPay payment of FLOONET_NAME_PRICE_GRIN, using the GoblinPay processor and the invoice tables. The quote endpoint responds with enough for a wallet to generate a pay page automatically.
Config
[name_authority]
enabled = true
domain = "relay.yourdomain" # 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
PaymentProcessortrait andaccountandinvoicetables, built for Lightning. floonet-rs adds a GoblinPay implementation of the same trait, so relays can be paid in GRIN instead.
What upstream provides
Upstream’s pay_to_relay model (nostr-rs-relay/src/payment/mod.rs) defines a PaymentProcessor trait with LNBits and CLN implementations, plus account and invoice tables tracking who has paid. floonet-rs keeps that skeleton and swaps the money.
What the GoblinPay processor does
The src/payment/goblinpay.rs implementation covers the trait’s lifecycle against a GoblinPay server:
- Create invoice. Ask GoblinPay for an invoice for the configured amount (
FLOONET_NAME_PRICE_GRINfor names, or the write-access price). GoblinPay returns the payment details the client needs, including the pay-page URL. - Confirm. Poll GoblinPay’s REST API for payment status. Confirmation is on-chain, with a Grin payment proof, so a confirmed invoice means real money moved. An invoice moves
open->paid(the payment landed) ->confirmed(its kernel reached GoblinPay’s house standard ofGP_CONFIRMATIONSon-chain confirmations, default 10); the paid gate and name registration only act onconfirmed. The status API exposes bothconfirmationsandconfirmations_required. - Record. Mark the invoice paid; the admission module’s paid gate and the name authority’s registration path both read that record. Lookups are cached with a TTL.
Enforcement points
- Paid writes (
FLOONET_PAY_MODE=write): the admission chain rejects events from pubkeys without a confirmed payment. - Paid names (
FLOONET_PAY_MODE=name):POST /api/v1/registeris refused until the quoted invoice confirms. Names get a dedicatedname_claimstable rather than overloadingaccount, while reusing the upstreaminvoicetable for the money side.
Payment UX
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 reachedGP_CONFIRMATIONSon-chain confirmations (default 10). Grant the goods onconfirmed.
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.
Setup: the wizard (recommended path)
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:
- the public URL customers reach this till at,
- your shop’s website URL (used to build the webhook URL),
- 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,
- 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, - restart mode: how the till comes back after a reboot (default unattended; see below),
- the currencies your shop prices in (default
usd), - 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 shippedgp-server.servicelooks (EnvironmentFile), and, in unattended mode,/etc/goblinpay/secrets/wallet_password(mode 0400) where itsLoadCredentialreads 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.confthat repoints the credential to a tmpfs path (/run/goblinpay/wallet_password), which you populate withsystemd-ask-passwordat each start./runis 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: take Grin on a WordPress/WooCommerce store.
- Medusa quick start: take Grin in a Medusa v2 store.
- API integration: integrate the REST API directly, the way Magick Market does.
- Hosting: subdomain vs zero-DNS path-prefix.
- Relay operators: to charge GRIN for names or writes on a Floonet relay, see Charge GRIN for your relay and The GoblinPay processor.
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 likehttps://myshop.com/payif 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:
| Field | Paste |
|---|---|
| GoblinPay URL | your till URL (e.g. https://pay.myshop.com) |
| API Token | the gp_live_… value from the wizard |
| Webhook Secret | the whsec_… value from the wizard |
| Matching mode | Per-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.mdin 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.envand changeGP_WEBHOOK_URLto your Medusa route (see step 3), thensudo 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.mdin 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):
| Field | Type | Notes |
|---|---|---|
amount_grin | integer | Exact amount in base units (nanogrin). 1 GRIN = 1_000_000_000 nanogrin. |
amount_fiat | string | Decimal fiat amount (e.g. "12.50"). Priced to Grin at create time via the rate oracle. |
currency | string | ISO code for amount_fiat (must be in GP_RATE_CURRENCIES). |
order_ref | string | Your order id. Used as the memo/subject match key and echoed back. Optional but recommended. |
memo | string | Human note shown on the checkout page. Optional. |
match_mode | string | Per-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_grinis base units (nanogrin), an integer. - In the response,
amountis 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.amountis base units (nanogrin) as an integer, andpayment.amount_grinis 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 reachedconfirmations_required(GP_CONFIRMATIONS, default 10). Grant the goods onconfirmed.
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_typeispayment.received(first seen) orpayment.confirmed(reachedGP_CONFIRMATIONS). Grant onpayment.confirmed.payment.amountis base units (nanogrin);payment.amount_grinis the human decimal string.payment.confirmationsis present only onpayment.confirmed.invoice_id/order_reftie the event back to your order (invoice_idmay 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
2xxmarks 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 theX-GoblinPay-Deliveryheader). A retried delivery repeats the sameevent_id, so make your handler idempotent: if you have already granted this order, just return2xx.
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
- WooCommerce quick start: the till URL is one of the two answers that matter.
- Medusa quick start: same till URL, different webhook route.
- API integration:
pay_urlin every invoice response is built from the public URL.
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
- The kind whitelist. Most abuse is simply not storable on a Floonet relay; disallowed kinds are dropped before any quota is touched.
- Per-connection event and subscription limits. Both upstreams provide these; the shipped configs set sane values.
- Per-IP HTTP windows on the name authority. Registration and lookup endpoints keep per-IP windows (reads generous, writes tight), fed by
X-Real-IPfrom the proxy, with limits loose enough that a shared Tor exit IP does not starve. The proxy forwardingX-Real-IPis load-bearing; without it every request appears to come from the proxy. - NIP-98 replay protection. Registration requests are single-use: the auth event’s timestamp bound and replay window mean a captured request cannot be replayed to burn a name.
- Name-change cooldown. A key that changes its name waits out a cooldown, which caps name-churn abuse at negligible cost to honest users.
Tuning
The shipped defaults are deliberately generous on reads and conservative on writes. If you tighten them, watch for the 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
| Mode | What is paid | Good for |
|---|---|---|
off | Nothing | Community relays, default |
name | Claiming a name | The common case: free relay, paid vanity names |
write | Publishing events | Invite-style relays where writing itself is the resource |
What your users experience
A user claiming a name from a wallet gets sent to your GoblinPay pay page, 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
- The relay’s public metadata stays payment-free. Charging for names does not change your NIP-11 document; the shipped defaults already comply.
- The GoblinPay token is a secret. Environment or 0400-mounted file, never the repo.
More things to charge for
Names are the first paid resource, not the last. The same gate can charge for media storage; see Media for GRIN.
Media for GRIN (NIP-96 / Blossom)
Summary. The paid gate is one mechanism applied to many resources. Names are the first implementation; media storage is the documented second: an operator charges GRIN for hosting files, over the same GoblinPay flow, via NIP-96 or Blossom.
The pattern
Everything paid in Floonet goes through one small interface over GoblinPay:
PaidResource {
quote() -> price + invoice for this resource
is_paid() -> has a confirmed payment for it
}
name is the first implementation (paid names). blob/media is the designed-for second, so a chat app or community can enable it by config without reworking payments.
The media case
A community running a chat app on a Floonet relay needs somewhere for images and files. The events referencing media are tiny; the bytes themselves need an HTTP host. Two standard shapes exist:
- NIP-96: HTTP file storage with a well-known discovery document and NIP-98-authenticated uploads.
- Blossom: content-addressed blobs, stored and fetched by sha256, advertised with a kind
10063server list.
Either way, the paid flow is identical to names:
- Client asks to upload; the server quotes a price (per upload, per MB, or per month, admin’s choice).
- The server creates a GoblinPay invoice; the user pays in GRIN.
- On confirmed payment, the upload is accepted and served.
Configuration sketch
FLOONET_PAY_MODE=name # names stay paid (or off)
FLOONET_MEDIA_ENABLED=true
FLOONET_MEDIA_PRICE_GRIN_PER_MB=0.1 # admin-set, like the name price
Status
Media-for-GRIN is documented as the extensibility example and kept modular as a later add-on, in line with the Floonet principle of shipping only what is needed now. The PaidResource seam and the GoblinPay flow it needs are already in both packages; enabling it is an add-on module, not a rework.
Config keys
The keys an operator actually touches, across both packages. Package-specific pages: floonet-strfry configuration, floonet-rs configuration.
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.
| Key | Default | Meaning |
|---|---|---|
FLOONET_ALLOWED_KINDS | the Goblin + Magick Market set (24 kinds) | The whitelist. Default deny; everything not listed is dropped. .env.example pins the wallet-only core. |
FLOONET_AUTHORIZED_AUTHORS | empty (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_AUTH | false | Reject writes unless the connection completed NIP-42 AUTH. |
FLOONET_PAY_MODE | off | off, name (pay to claim a name), or write (pay to publish). |
FLOONET_NAME_PRICE_GRIN | unset | Price of a name in GRIN. Required when FLOONET_PAY_MODE=name. |
GOBLINPAY_URL | unset | The operator’s GoblinPay server. Required for any paid mode. |
GOBLINPAY_TOKEN | unset | GoblinPay API token. Secret: environment or 0400 file only. |
FLOONET_PAID_CACHE_SECS | 60 | TTL for the plugin’s cached paid-status lookups. |
FLOONET_TRANSFERS | false | Turn on the authority’s non-custodial name marketplace. Off by default; when on, FLOONET_GRIN_NODE_URL is required. Authority only. |
FLOONET_GRIN_NODE_URL | unset | Read-only Grin node foreign API used to confirm transfer payments. Required when FLOONET_TRANSFERS=true. |
floonet-strfry (strfry.conf)
| Key | Floonet default | Meaning |
|---|---|---|
relay.info.name | Floonet Relay | NIP-11 name, payment-neutral. |
relay.info.description | A strfry Floonet relay for the Grin community Nostr network. | NIP-11 description, same rule. |
relay.writePolicy.plugin | plugin path | The write-policy plugin. |
relay.auth.enabled | false | NIP-42 authentication. |
events.maxEventSize | shipped default | Keep large enough for gift-wrapped slatepacks. |
The read side may also set filterValidation.allowedKinds to mirror the whitelist on subscriptions.
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)
| Key | Floonet default | Meaning |
|---|---|---|
info.name | floonet-rs-relay | NIP-11 name, payment-neutral. |
info.description | neutral Floonet wording | NIP-11 description, same rule. |
limits.event_kind_allowlist | the same 24-kind set | The whitelist, enforced in admission. |
limits.max_event_bytes | shipped default | Keep large enough for gift-wrapped slatepacks. |
authorization.public_note_authors | unset (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_auth | false | Require AUTH before writes. |
authorization.nip42_dms | false | Require AUTH to read your gift wraps. |
authorization.pubkey_whitelist | unset | Restrict writes to these pubkeys. |
goblinpay.pay_mode | off | off, name, or write; drives the GoblinPay processor. Env alias FLOONET_PAY_MODE. |
goblinpay.url | unset | Your GoblinPay server. Env alias FLOONET_GOBLINPAY_URL. |
goblinpay.api_token | unset | GoblinPay API token; prefer the FLOONET_GOBLINPAY_TOKEN env var over the file. |
goblinpay.name_price_grin | 1.0 | Price of a name in GRIN when pay_mode = "name". Env alias FLOONET_NAME_PRICE_GRIN. |
goblinpay.admission_price_grin | 1.0 | Price of write admission in GRIN when pay_mode = "write". |
name_authority.enabled | false | Serve the name authority in-process. |
name_authority.domain | operator’s domain | The 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_url | operator’s domain | LOAD-BEARING: NIP-98 auth events are verified against <base_url><path>; must match the relay’s public URL. |
Endpoints
The relay
| Endpoint | Protocol | Purpose |
|---|---|---|
wss://relay.yourdomain/ | Nostr over websocket | The relay itself |
https://relay.yourdomain/ with Accept: application/nostr+json | HTTP | The NIP-11 information document |
https://relay.yourdomain/ (browser) | HTTP | A neutral Floonet landing page with the Floonet logo |
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).
| Endpoint | Auth | Purpose |
|---|---|---|
GET /.well-known/nostr.json?name={name} | none | NIP-05 resolution: returns {"names": {"{name}": "<pubkey>"}} |
POST /api/v1/register | NIP-98 | Claim a name for the signing key. Refused until payment confirms when FLOONET_PAY_MODE=name; the refusal response carries the quote and invoice so a wallet can generate the pay page. |
DELETE /api/v1/register/{name} | NIP-98 | Release a name (only by its owner) |
GET /api/v1/by-pubkey/{pubkey} | none | Reverse lookup: the name currently held by a key |
GET /api/v1/profile/{name} | none | Profile data for a name |
GET /api/v1/name/{name} | none | Availability: is this name free, reserved, or taken |
GET /api/v1/health | none | Health probe for monitoring |
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.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/v1/transfer/offer | NIP-98 (seller) | Lodge a signed kind-3402 sale offer |
GET /api/v1/transfer/offer/{id} | none | Read an offer and its status |
DELETE /api/v1/transfer/offer/{id} | NIP-98 (seller) | Revoke a live offer |
POST /api/v1/transfer/claim | NIP-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
| Kind | NIP | Name | Why Floonet carries it |
|---|---|---|---|
0 | 01 | Profile metadata | Display names and avatars, so contacts render as people |
3 | 02 | Contact list | Follow and contact lists |
5 | 09 | Deletion request | Lets users retract their own events |
13 | 59 | Seal | The inner encrypted layer of a gift wrap |
1059 | 59 | Gift wrap | The opaque envelope everything private travels in |
10002 | 65 | Relay list | Where a user can be found |
10050 | 17 | DM relay list | Where to deliver a user’s private messages |
24133 | 46 | Nostr Connect | Remote signing (ephemeral); wallet login |
24140 | Goblin | Authorize Sessions | Ephemeral session channel for the Authorize Sessions feature |
27235 | 98 | HTTP auth | Authenticates name-authority registration requests |
Magick Market marketplace kinds
The marketplace also reuses 0, 5, 1059, and 10002 above.
| Kind | NIP | Name | Why Floonet carries it |
|---|---|---|---|
1 | 01 | Text note | Bug reports, shared listings |
7 | 25 | Reaction | Likes on listings and posts |
14 | Gamma | Order chat | Plaintext order messages in the market’s order flow |
16 | Gamma | Order status | Order processing and status updates |
17 | Gamma | Payment receipt | Payment confirmation in the order flow |
1111 | 22 | Comment | Threaded comments |
10000 | 51 | Mute list | Merchant/product blacklist |
30000 | 51 | People set | Admins, editors, featured users |
30003 | 51 | Bookmark set | Featured collections |
30023 | 23 | Long-form article | News and long-form posts (author-locked, see below) |
30078 | 78 | App-specific data | Cart, relay preferences, registries |
30402 | 99 | Product listing | Classified listing / product |
30405 | Gamma | Product collection | Collections and featured products |
30406 | Gamma | Shipping option | Shipping options in the order flow |
31990 | 89 | Handler information | Marketplace 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 aKEY=VALUEfile namedfloonet.envnext to the plugin, path overridable viaFLOONET_ENV_FILE; real environment variables win, andtouching the plugin reloads it with no restart). - floonet-rs:
authorization.public_note_authorsinconfig.toml.
Invalid entries are logged and skipped; the rest still apply.
Excluded on purpose
| Kind | Why it is rejected |
|---|---|
9735 | Lightning zap receipts; dead in this GRIN-only ecosystem |
25910 | ContextVM; only ever rides inside a 1059 gift wrap, never raw |
30017/30018 | Legacy 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_KINDSin both packages and the policy running onrelay.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/.