Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Goblin

New here? Start with the Quick start →Set up your wallet, get paid, and pay someone in about two minutes.

Goblin is a private, mobile-first payments app for Grin. Think Cash App, but the money is Grin, your address is a Nostr key (an npub), and the whole conversation can ride Tor.

You type alice instead of pasting a 90-character key: a username is an optional, friendly alias that the wallet resolves to the real address (the npub) behind the scenes. You tap Pay/Request, hold to confirm, and a Grin payment travels end-to-end encrypted over the Nostr network, so no relay, no network observer, and no chain analyst can tie the sender to the receiver. Turn on Tor routing (on by default for wallets that update; a per-wallet choice for new ones) and your IP is hidden from the relay too.

Goblin home: balance, recent contacts, and an activity feed, connected over Tor (optional)

Goblin home, connected over Tor (optional).

Under the hood Goblin stands on three pillars:

PillarWhat it gives Goblin
GRIMA complete, audited Grin wallet + node engine: seed, sync, and the Mimblewimble slatepack transaction machinery. Goblin forks it and keeps it.
NostrThe messaging layer. Addresses (npub keys) with optional usernames on top, encrypted payment messages (gift-wrapped slatepacks), and offline delivery, all without running our own bespoke server.
TorThe transport, as a per-wallet switch. With Tor routing on, all relay traffic and every HTTP request rides Tor, embedded in-process, out through a Tor exit to each destination’s ordinary clearnet host. The public Grin chain connection is always direct, by design.

Goblin is also one piece of a larger Grin-native stack: the GoblinPay till, the Magick Market marketplace, and Floonet relays all speak the same wallet-to-wallet payment rail. For a tour of how they fit together, and how to self-host them, see goblin.st/use-cases.

How to read these docs

The docs are organized from the outside in:

  1. Overview: what Goblin is and how the pieces fit.
  2. Pillars: the three foundations (GRIM, Nostr, Tor), each broken into its component parts.
  3. Features: the things you actually do: pay, request, claim a name, onboard.
  4. Subsystems: the smaller machinery: themes, avatars, QR, localization, security.
  5. Operating Goblin: run your own name authority and relay; build from source.

Every component page follows the same shape:

Summary: one paragraph, what it is. Motivation: why it exists, the problem it solves. How it works: a plain-language walkthrough, with screenshots. Reference: the technical detail: types, functions, wire formats. References: links into the source (file:line) and the external standards.

Goblin is open source. Where these docs cite code, they point at the public source tree so you can read along.

Quick start

New to Goblin? This is everything you need to send and receive money. It takes about two minutes.

Goblin is like Cash App, but for Grin. You pay a username (an easy alias for your friend’s real address, their npub), your money stays private, and there’s nothing to set up beyond a wallet.

1. Set up your wallet

The first time you open Goblin:

  1. Tap Get started. Goblin connects you to the Grin network automatically, so there’s nothing to set up.
  2. Pick a wallet name and a password.
  3. Goblin shows your recovery phrase: a list of words. Write them down on paper and keep them somewhere safe. These words are your money. If you lose them, nobody can get your funds back for you.
  4. Re-enter the words to confirm you saved them.
  5. Your wallet already has an address, a long key called an npub. Optionally pick a username like yourname, a friendly alias that points to your npub, so friends can pay you without copying the key. Or tap Skip to stay anonymous.

That’s it. You’re ready.

2. Get paid (receive)

You receive money by sharing your username or your code.

Share your handle

  1. Tap Receive.
  2. Show your QR code, or tap Share or Copy to hand over your payment code (an nprofile, your address plus a hint of where to reach you). Your username works too, if you claimed one.
  3. Send that to whoever is paying you.

When they pay, it lands in your wallet on its own, even if your app was closed.

Ask for a specific amount

  1. Open Pay/Request (the button) and enter the amount you want.
  2. Tap Request.
  3. Pick a contact or share the request. They get a tap-to-pay request.

3. Pay someone (send)

  1. Tap the big button in the middle to open Pay/Request.
  2. Type their username or npub, or tap the scan icon to scan their QR code.
  3. Enter the amount.
  4. Optional: add a short note.
  5. Check the details, then press and hold the send button until it fills.

Done. Your payment goes out encrypted and private. The other person’s wallet finishes it automatically, even if they were offline when you paid.

4. Check a payment

Tap Activity to see everything you’ve sent and received, and whether each one has confirmed yet.

Good to know

  • Your recovery phrase is your money. Back it up on paper. Goblin can’t reset it for you, and support can’t either.
  • Payments are private by default. They travel encrypted, and the network hides who paid whom. Here’s how.
  • Private by choice. Goblin can route its payment network over Tor, running right inside the app, so the relay never sees your IP. It’s a per-wallet switch under Settings → Privacy → Tor routing: a wallet you updated from an older version keeps Tor on, and a brand-new wallet asks you during setup (off means faster to connect but the relay can see your IP, so pair it with a VPN if that matters to you). With Tor on the money path still connects in a couple of seconds; the very first connection can take a moment while Tor warms up, which is normal.
  • Paying someone who isn’t on Goblin? Use Settings → Wallet → Slatepacks for the by-hand method.
  • On Android, the back button won’t dump you out of the app by accident. On the wallet home screen, press back twice to return to the wallet switcher; the first press shows a “Press back again to switch wallets” hint. The confirmation to exit the app lives at the wallet switcher.

Want the full picture of how it all works? Start with What is Goblin?

What is Goblin?

Summary. Goblin is a mobile-first wallet for Grin that lets you pay a username or npub instead of swapping transaction files. (The npub, a Nostr key, is the actual address; a username is an optional alias that resolves to one.) It is a fork of the GRIM Grin wallet, with a Nostr-based messaging layer and an embedded Tor transport bolted on so payments are end-to-end encrypted and metadata-private from the network up.

The problem Goblin solves

Grin is built on Mimblewimble: there are no addresses and no amounts on the chain, which makes it one of the most private cryptocurrencies in existence. But that privacy comes with a famously awkward UX. Grin transactions are interactive: to build one, the sender and receiver each have to contribute to a “slate,” passing a slatepack file back and forth at least once before the payment is final.

In practice that means emailing files, pasting blobs into chat, or both parties being online at the same time. It works, but nobody would call it Cash App.

Goblin’s thesis is simple: keep Grin’s on-chain privacy, and make the off-chain handshake feel like sending a text.

How it feels

You open Goblin to a single balance and a Pay/Request button. You type alice (or paste an npub), enter an amount, add an optional note, and hold to send. Alice’s wallet (even if it was closed when you paid) picks the payment up the next time it connects, finishes its half of the transaction automatically, and the money settles on the Grin chain. Neither of you ever saw a slatepack.

Screenshot: Home tab with balance and the Pay/Request puck (mobile 390×844, dark theme).

What makes that possible

Three things, each documented in depth in these pages:

  • A real Grin wallet underneath. Goblin doesn’t reimplement Grin; it forks GRIM and keeps its full node + wallet engine: seed and key management, chain sync, and the slatepack transaction state machine. Everything Goblin adds sits on top of an unmodified, audited wallet core.

  • Nostr as the courier. The slatepack is wrapped as an encrypted Nostr message and delivered through public relays. Relays buffer messages for wallets that are offline, so the exchange is asynchronous. Relays only ever see ciphertext, never the amount, the sender, or the recipient. Your address is your Nostr key (your npub); a username like alice is an optional NIP-05 alias that resolves to it.

  • Tor for network privacy. With Tor routing on, every connection Goblin makes (relay sockets and every HTTP request for name lookups, price, avatars) is tunneled through Tor, embedded right inside the app, out through a Tor exit to each destination’s ordinary clearnet host. The relay, and anyone watching the network, sees a Tor exit address, never your phone’s IP. That’s Tor’s one narrow job; the relay and the Nostr protocol hide the rest (the message content, the sender, and the send/receive timing) whether Tor is on or off. Tor routing is a per-wallet setting: a wallet you update keeps it on, a new wallet chooses during onboarding, and with it off the same encrypted traffic goes direct so the relay can see your IP. The Grin node connection carries only public chain data and is always kept direct.

What stays the same as Grin

Goblin is still a self-custodial Grin wallet. Your funds are controlled by your seed phrase; your transactions are confidential Mimblewimble transactions; you can run your own node or use an external one. If you ever need to pay someone who isn’t on Goblin, the classic by-hand slatepack flow is still there under Settings → Wallet → Slatepacks.

Where to go next

References

Architecture: the three pillars

Summary. Goblin is layered. A GRIM wallet/node core handles money; a Nostr layer handles messaging and identity; a Tor layer handles transport. The UI ties them into a Cash-App-style experience. Each layer is replaceable in principle and isolated in the code.

The stack, top to bottom

┌─────────────────────────────────────────────────────────┐
│  Goblin UI   (src/gui/views/goblin/)                     │
│  Home · Pay/Request · Activity · Receive · Me · Send flow│
├─────────────────────────────────────────────────────────┤
│  Nostr messaging   (src/nostr/)                          │
│  identity · gift-wrapped slatepacks · ingest policy ·    │
│  relays · NIP-05 names · per-wallet service thread       │
├─────────────────────────────────────────────────────────┤
│  Tor transport   (src/tor/)                              │
│  embedded arti -> a Tor exit -> each relay's clearnet host│
│  ALL relay sockets + HTTP go through here                │
├─────────────────────────────────────────────────────────┤
│  GRIM wallet + node engine   (wallet/, node/)            │
│  seed · keys · sync · Mimblewimble slatepack tx machine  │
└─────────────────────────────────────────────────────────┘
        │                                   │
        Tor                          Grin node (direct,
 (identity + payments               public chain data,
  + price + avatars)                 not tied to identity)

Why these three

Each pillar exists because the layer below it leaves a gap:

  • GRIM gives a correct, complete Grin wallet, but its native payment UX is file-swapping. Gap: usability.
  • Nostr closes that gap: it turns the slatepack handshake into encrypted, store-and-forward messaging addressed by key, with human usernames on top. It also hides the content, the sender (a throwaway one-time key), and — via our own relay’s randomized release delay — the timing. Gap it leaves: the relay still sees your IP.
  • Tor closes that one gap: the relay is the machine you connect to, so it’s the only piece that can’t hide your network location by itself. A Tor-exit circuit shows the relay a Tor address, never your phone. Result: who-pays-whom is private from the chain up and the network down.

That division of labor is the whole design: Tor hides your network location from the relay; the relay and the Nostr protocol hide everything else. It’s why Goblin needs Tor for just one narrow job — see Tor in Goblin.

What rides which transport

A deliberate split (see the transport page). The Tor rows below apply when Tor routing is on (the default for updated wallets, and a per-wallet choice for new ones); with it off those same rows go direct, still end-to-end encrypted but exposing your IP to the relay. The Grin node row is always direct.

TrafficPathWhy
Nostr relay sockets (payments, identity events)Tor (a Tor-exit circuit to the relay’s clearnet host)Reveals your network location to the relay; must be hidden.
NIP-05 name lookups (HTTP)Tor (a Tor-exit circuit to the name authority’s clearnet host)Reveals who you’re about to pay.
Price feed, avatars (HTTP)Tor (out through an exit relay to the clearnet host)Reveal your IP and tie you to the app.
DNSNone on the device (every hostname resolves at the Tor exit)A clearnet lookup would announce which relays and hosts you contact.
Grin node connection (block sync, broadcast)DirectPublic chain data, identical for everyone, not tied to your identity. Anonymizing it buys little and costs reliability.

Code map

LayerDirectoryStart here
UIgoblin/src/gui/views/goblin/mod.rs (GoblinWalletView)
Nostrgoblin/src/nostr/mod.rs, client.rs
Torgoblin/src/tor/the arti engine, copied from GRIM
Wallet↔Nostr gluegoblin/src/wallet/wallet.rsWalletTask::Nostr*
GRIM coregoblin/wallet/, goblin/node/inherited from GRIM
Identity servergoblin-nip05d/ (sibling crate)the NIP-05 authority

A payment in one breath

You tap Pay → GRIM builds a slatepack → the Nostr layer gift-wraps it and publishes it to relays → the bytes leave your machine over a Tor exit, to the relay’s clearnet host → the recipient’s wallet ingests it, auto-builds its half, and replies the same way → your wallet finalizes and GRIM broadcasts to the Grin node. The payment-flow page walks every step.

References

  • Layer directories: goblin/src/{gui/views/goblin,nostr,tor}/, goblin/wallet/, goblin/node/.
  • Transport split rationale: goblin/README.md; see also Tor in Goblin.

GRIM: the wallet engine Goblin forks

Summary. Goblin is a fork of GRIM, a cross-platform Grin wallet and integrated node written in Rust on egui. Goblin keeps GRIM’s entire money engine (seed/key management, node and chain sync, and the Mimblewimble slatepack transaction state machine) unmodified, and adds its payments experience in new modules alongside it.

Motivation

Writing a correct cryptocurrency wallet is hard and security-critical; writing a Grin wallet and a full Grin node is harder still. GRIM had already done that work: a mature, audited egui app with the complete Grin stack vendored in. Rather than reimplement any of it, Goblin forks GRIM and treats it as a stable foundation, so all of Goblin’s new code is about messaging and transport, not about money primitives. This keeps the risky surface (key handling, transaction building, consensus) on well-trodden upstream code.

How it works

GRIM bundles the Grin node and wallet libraries as path dependencies and drives them from an egui UI. Goblin inherits all of that:

  • Seed & keys. BIP-39 mnemonic (12–24 words), the wallet master seed, and Grin’s output/rangeproof key derivation: all GRIM/Grin code. Your Grin funds are controlled by this seed. (Goblin’s nostr identity is deliberately separate; see Identity.)
  • Integrated node + sync. GRIM can run a full Grin node or talk to an external one, track the chain tip, and scan for your outputs. Goblin exposes this under Settings → Advanced but does not change it.
  • The slatepack transaction machine. Grin’s interactive flow (Standard (send) and Invoice (request), each a two-step slate exchange) and the slatepack armor encoding live in the Grin wallet library. Goblin’s entire job is to carry these slatepacks; it never alters how they’re built or validated.
  • The egui shell & platform layer. Window, fonts, Android/desktop entry points, camera, and storage abstractions come from GRIM.

What Goblin adds lives in two new trees that GRIM doesn’t have (src/nostr/ and src/gui/views/goblin/), plus a src/tor/ transport copied from GRIM’s own proven Tor engine and pointed at the relay, and a handful of hooks into the wallet lifecycle (src/wallet/wallet.rs) to start/stop the Nostr service and dispatch WalletTask::Nostr* jobs.

What Goblin changes in upstream is intentionally minimal: it swaps the default presented surface to the Goblin UI, routes all of its Nostr and HTTP traffic through that embedded Tor transport, and rebrands. The original GRIM tree is kept side-by-side (at ../grim) precisely so the fork can be diffed and stay close to upstream.

Reference

  • Crate identity. The package is still named grim (version = "0.3.6"), the binary is goblin; see goblin/Cargo.toml ([package], [[bin]]). The node/wallet libraries are path deps: grin_api = { path = "node/api" }, grin_chain, grin_wallet_*, etc.
  • Versioning is build-number based, off the fork point. goblin/build.rs defines GOBLIN_FORK_BASE = "b51a46b" (the GRIM commit Goblin forked from) and computes Build N = number of commits since the fork via git rev-list. An explicit GOBLIN_BUILD env var overrides it (used by CI single-commit public builds). So Goblin ships “Build 97,” not a semver.
  • Release profiles. [profile.release] strips symbols (the tor+nostr+grin tree is ~16 MB of symbols); [profile.release-apk] adds opt-level="z", lto, panic="abort" for Android size.
  • Upstream. GRIM lives at https://code.gri.mw/GUI/grim (author Ardocrat). Goblin’s repository field still points there.

To see exactly what the fork changed, diff the two trees:

diff -ru ../grim/src ./src        # new: nostr/, gui/views/goblin/; tor/ from GRIM
diff -ru ../grim/Cargo.toml ./Cargo.toml

References

  • goblin/Cargo.toml: package name, binary, path deps, release profiles.
  • goblin/build.rs:5-6: GOBLIN_FORK_BASE = "b51a46b"; build-count logic at :11-34.
  • goblin/README.md: “Goblin is a fork of the Grim egui GRIN wallet…”.
  • Reference copy of upstream GRIM: ../grim (sibling of the goblin tree).
  • egui: https://github.com/emilk/egui.

Nostr in Goblin

Summary. Goblin uses Nostr as its messaging and identity layer. A Grin slatepack is wrapped as an encrypted Nostr direct message and delivered through public relays; your identity is a Nostr keypair with an optional human-readable NIP-05 username. Relays buffer messages, so payments are asynchronous; relays see only ciphertext.

Why Nostr

The hard part of a Grin payment is getting the two slatepack legs between sender and receiver. That is a messaging problem: a small encrypted blob needs to reach a specific recipient, who might be offline, identified by something friendlier than a 90-character address.

Nostr is a good fit because it already solves the boring parts:

  • Addressing by key. Every user is a public key; you message a key.
  • Store-and-forward. Relays hold events for offline clients and deliver them on reconnect: exactly the asynchronous mailbox an interactive payment needs.
  • A real encryption story. NIP-17 / NIP-44 / NIP-59 give sealed, gift-wrapped DMs where relays can’t read the content or see the real sender.
  • Human names without a blockchain. NIP-05 maps alice@goblin.st to a key over plain HTTPS.
  • An existing, decentralized network. No bespoke server to run; any relay works, and you can run your own.

Goblin could have built a custom relay (the way grinbox did for Grin years ago). Using Nostr instead means inheriting a maintained ecosystem and standard, audited encryption, and adding Tor underneath to hide the one thing Nostr alone can’t: your network location from the relay.

The parts

The Nostr layer (goblin/src/nostr/) breaks into six components, each with its own page:

PageWhat it coversKey file
IdentityThe nostr keypair, encryption at rest, rotationidentity.rs
Payment protocolHow a slatepack becomes a gift-wrapped eventprotocol.rs
The NostrServiceThe per-wallet relay thread + send pipelineclient.rs
Ingest policyWhat the wallet accepts, and what it never doesingest.rs
Storage, config & typesThe metadata archive and per-wallet settingsstore.rs, config.rs, types.rs
RelaysDefaults, the candidate pool, DM relay lists, the editorrelays.rs, pool.rs

The NIPs Goblin implements

NIP-05 (names), NIP-06 (key derivation), NIP-11 (relay information, as the pool gate), NIP-17 (private DMs), NIP-19 (npub/nprofile encoding), NIP-44 (encryption, v3 with v2 fallback), NIP-49 (encrypted key at rest), NIP-59 (gift wrap), NIP-65 (relay lists), NIP-98 (HTTP auth). Each is cited on the page where it’s used.

References

Identity (NIP-06 / NIP-49)

Summary. Each wallet has a Nostr keypair that is its payment identity. The secret key is stored encrypted at rest (NIP-49 ncryptsec, owner-only file permissions). Crucially, this key is separate from your Grin seed, so you can rotate your identity to stay unlinkable without ever touching your funds. A wallet can also hold several identities at once, all listening together; see Multiple identities.

Motivation

Two design tensions shape Goblin’s identity:

  1. Linkability. If your payment identity were derived from your wallet seed, it would be permanent: every payment forever tied to one key. Goblin wants you to be able to start fresh. So the nostr key is independent and rotatable.
  2. Safety at rest. The secret key sits on a phone. It must never be on disk in the clear, and the file must not be world-readable.

How it works

Your identity lives in wallet_data/nostr/identity.json, written with Unix mode 0600 inside a 0700 directory. Inside, the secret key is a NIP-49 ncryptsec: a bech32 blob encrypted with your wallet password via scrypt (work factor log_N = 16, ~64 MiB, interactive grade). The file also stores your npub in the clear (so the UI can show “you” before you’ve unlocked), your nip05 name if you’ve claimed one, an anonymous flag, and prev_npubs, a history of keys you’ve rotated away from.

There are three ways an identity comes to exist (IdentitySource):

  • Random (default): a brand-new independent key (Keys::generate). Unlinkable to your seed and to any other wallet.
  • Imported: you paste an nsec or restore an encrypted backup file, adopting an existing identity (name and history included).
  • Derived: a NIP-06 seed-derived key. Kept for legacy wallets; new wallets use Random.

Starting a fresh, unlinkable key does not need a dedicated rotate action, and the old one was removed: it was only a random add-and-switch that also released your name, adding nothing over the identity switcher. To present as a new independent key you add an identity (a random Keys::generate key, unlinkable to your seed and your other identities) and switch to it, all without re-seeding, so your Grin balance is untouched. Adding a new key does not carry your username or profile to it; to keep a name on a new key, transfer the name through the authority. See Multiple identities. The prev_npubs field still records keys a wallet has moved away from.

Screenshot: Settings → Identity card (Copy npub, Back up to a file, and the identity switcher), dark theme.

Backup & restore. As of Build 158 the backup (Settings → Advanced, under Advanced nostr settings, captioned “Contains your wallet and all identities.”) writes one sealed, encrypted .backup file that holds both your Grin money seed and every identity the wallet holds, with the active one marked. One file now moves a whole wallet, funds and names together, so there is no longer a separate seed-phrase-plus-identity-file dance. Restoring it is a single step in onboarding: the restore flow’s “Choose a .backup file” picker takes the file, you unlock it with the wallet password, and it fills the 24-word recovery grid for you; once the wallet opens, the identities restore automatically (a “Restoring your identities…” card shows the progress). Older single-identity backups still restore: importing one adopts that one identity (name and history included), decrypting with its export-time password and re-encrypting under the new wallet’s password.

Logging in to other nostr apps. Settings → Advanced → Nostr key reveals your nsec behind your wallet password: enter the password, then copy the key or show it as a QR. Scanning that QR (or pasting the copied nsec) into another nostr app’s private-key login, for example magick.market, signs you in with the same identity your wallet uses. The key is derived on demand behind the password and never persisted in the clear; a wrong password reveals nothing.

Reference

  • IdentitySource enum and the NostrIdentity struct: goblin/src/nostr/identity.rs. Fields: ncryptsec, npub, nip05, anonymous, prev_npubs.
  • Encryption at rest: NIP-49 ncryptsec, scrypt NCRYPTSEC_LOG_N = 16.
  • File safety: write_private() (Unix 0600), restrict_dir() (0700); stored at wallet_data/nostr/identity.json.
  • Key derivation for the legacy Derived source: derive_keys() (NIP-06 BIP-44 path).
  • Rotation/import/backup UI: RotateState / ImportState / BackupState flows in goblin/src/gui/views/goblin/mod.rs; onboarding import in onboarding.rs (OnbImport).

References

The payment protocol (NIP-17 / 44 / 59)

Summary. A Grin slatepack is delivered as a NIP-17 private direct message: a kind 14 rumor carrying the slatepack armor, sealed and gift-wrapped (NIP-59) inside a kind 1059 event encrypted with NIP-44 (v3 when both wallets support it, v2 otherwise). Relays see only the wrap: not the content, not the real sender, not the timestamp.

Motivation

The courier needs three properties Grin’s bare slatepack doesn’t have on its own:

  1. Confidentiality: a relay must not read the slatepack (it would reveal a pending payment and its amount).
  2. Sender privacy: a relay must not even learn who sent the message.
  3. A stable, versioned wire format: so two Goblin wallets (and other NIP-17 clients) agree on how to read it.

NIP-17/44/59 give the first two for free; Goblin adds a thin, explicit protocol on top for the third.

How it works

A payment message is built in layers:

kind 14 rumor   ── content: PREAMBLE + "\n\n" + <slatepack armor>
   (unsigned)      tags: ["goblin","1"]  + optional ["subject", note]
      │
   NIP-59 seal (kind 13)  ── signed by the REAL sender, NIP-44 encrypted
      │
   NIP-59 gift wrap (kind 1059) ── signed by a throwaway EPHEMERAL key,
                                    NIP-44 encrypted to the recipient,
                                    timestamp randomized into the past
      │
   published to relays
  • The content starts with a human-readable preamble ("[Goblin] GRIN payment message: open in Goblin (https://goblin.st) to process."), then a blank line, then the raw BEGINSLATEPACK…ENDSLATEPACK armor. Other NIP-17 clients render something legible; Goblin extracts the slate.
  • A ["goblin","1"] tag marks the protocol and its version. Classification never trusts tags: the wallet decides what a message is only by parsing the slate itself.
  • An optional ["subject", …] tag carries the payment note (sanitized, capped at 256 chars).
  • Because the gift wrap is signed by an ephemeral key and the timestamp is randomized, a relay can’t link the message to the sender or place it in time. The real sender is recoverable only after the recipient decrypts the inner seal.

Encryption: NIP-44 v3 with v2 fallback

Both encrypted layers (the seal and the gift wrap) use NIP-44. Goblin speaks v3 and negotiates it per recipient, with v2 as the always-safe fallback:

  • Advertising. Your kind 10050 DM-relay list carries an encryption tag with the wallet’s capabilities, space-separated best-first: nip44_v3 nip44_v2.
  • Sending. If the recipient’s 10050 advertises nip44_v3, the wrap is built with v3: the seal’s ciphertext is cryptographically bound to kind 13 and the wrap’s to kind 1059, so ciphertext produced for one layer cannot be replayed as the other. Everything else (tags, ephemeral wrap key, timestamp fuzzing) mirrors the v2 builders exactly. No tag, or a v2-only tag, means v2.
  • Receiving. Unwrapping dispatches on the payload’s version byte (0x02 is v2, 0x03 is v3); unknown versions and malformed payloads are rejected cleanly. A wallet that only speaks v2 is completely unaffected: it receives v2 wraps and its own wraps still decrypt.

Control messages (void)

Cancelling or declining a request is the same wire message (“this request is off”), differing only by who sends it. It’s a kind 14 rumor tagged ["goblin-action","void", <slate_id>], gift-wrapped the same way. The receiver reads the goblin-action tag and voids the matching request. See Cancel & decline.

Size ceilings

Hard limits are enforced before doing any work, as a denial-of-service guard:

LimitValue
Gift wrap content (before unwrap)64 KiB
Rumor content (after unwrap)32 KiB
Slatepack armor30 KiB
Note (after sanitize)256 chars

Reference

All in goblin/src/nostr/protocol.rs:

  • Constants: MAX_WRAP_CONTENT, MAX_RUMOR_CONTENT, MAX_SLATEPACK, MAX_NOTE_CHARS; GOBLIN_TAG = "goblin", PROTOCOL_VERSION = "1", GOBLIN_ACTION_TAG = "goblin-action", ACTION_VOID = "void", PREAMBLE.
  • Builders: build_payment_content(), build_rumor_tags(), build_control_content(), build_control_tags().
  • Parsers: extract_slatepack() (matches exactly one armor block), extract_subject(), extract_control(), sanitize_note().
  • v2 sealing/wrapping is handled by the nostr-sdk gift-wrap APIs; v3 by goblin/src/nostr/wrapv3.rs (ENCRYPTION_CAPABILITY, peer_supports_v3(), wrap(), and the version-dispatched unwrap()). Both are driven from the send pipeline, which reads the recipient’s capability from their kind 10050.

References

The NostrService relay thread

Summary. NostrService is the long-running, per-wallet engine that connects to relays (over Tor), publishes payment messages, watches for incoming ones, and exposes send progress to the UI. Each open wallet has its own service and its own relay pool; there is no global connection.

Motivation

A wallet that pays by message needs a persistent worker: something that keeps relay sockets alive, subscribes for gift wraps addressed to you, runs the send pipeline off the UI thread, and survives for the life of the open wallet. Bundling that into one owned object (started on Wallet::open, stopped on Wallet::close) keeps the relay lifecycle tied to the wallet lifecycle and keeps per-wallet state (keys, rate limits, send status) isolated.

How it works

When a wallet opens with Nostr enabled, it spawns a NostrService on a dedicated tokio runtime. The service:

  • Holds the decrypted keys in memory only (never re-serialized to disk) and builds a nostr-sdk client whose relay transport is the Tor websocket transport when Tor routing is on, so every relay socket runs over Tor; in clearnet mode the same client dials the relays directly.
  • Subscribes for kind 1059 gift wraps addressed to your key, with a 3-day lookback (NIP-59 randomizes timestamps up to ~2 days into the past, so the window must be generous). Incoming events flow into the ingest policy.
  • Runs the send pipeline: build rumor → seal → gift wrap → publish to your relays and the recipient’s DM relays. Progress is published to the UI through an atomic send_phase (IDLE → WORKING → SENT / FAILED, plus REQUEST_BLOCKED), with a human-readable reason on failure.
  • Rate-limits incoming senders to blunt spam: a known contact may send ~30 events/hour, an unknown key ~10/hour.
  • Re-verifies names on a rolling basis (a few contacts per tick, on a periodic heartbeat) so a contact whose name was reassigned or released is caught.
  • Reports relay liveness to the transport layer: the service tells the Tor client which circuit generation its relays are connected on, which both drives the honest “Connected over Tor” indicator and lets the transport rebuild a circuit that can’t carry relay traffic.
  • Serializes cancel vs. finalize with a lock, so a user-initiated cancel can’t race a concurrent auto-finalize of the same slate.
  • Raises a system notification on Android for the two events worth interrupting you for: a payment landing (AutoReceive) and someone requesting one (SurfaceRequest), each a one-shot, fail-open alert that fires once per event and never delays ingest itself. The persistent “Listening for payments” notification is separate: it just says the service is alive, and stays up the whole time a wallet is open.

It also answers one-shot queries the UI needs: fetch_profile_blocking() (pull a kind 0 profile to verify a pasted key), nprofile() (your shareable NIP-19 profile with relay hints), and nsec() (plaintext key for an explicit user backup only).

Reference

In goblin/src/nostr/client.rs:

  • NostrService struct: keys, client (relay pool), rt_handle, connected, per-sender rate map, send_phase (atomic) + last_send_error, cancel_finalize_lock.
  • send_phase constants: IDLE=0, WORKING=1, SENT=2, FAILED=3, REQUEST_BLOCKED=4.
  • One-shots: public_key(), nprofile(), nsec(), keys(), fetch_profile_blocking().
  • Lifecycle hooks: Wallet::open / close / start_sync in goblin/src/wallet/wallet.rs; jobs arrive as WalletTask::Nostr*.
  • Relay transport: the arti-backed Tor websocket transport (goblin/src/tor/).

References

Ingest policy (the security core)

Summary. Every incoming message runs through one pure decision function, decide(), before the wallet does anything with it. This is where Goblin enforces its safety invariants: a request for you to pay is never paid automatically, and a reply is only finalized when it matches a payment you started and comes from the counterparty you expected. Everything else is dropped.

Motivation

A wallet that auto-processes messages from strangers is a wallet waiting to be drained or confused. The ingest policy exists so that “what does the wallet do with this slate?” has exactly one answer, derived purely from the slate’s contents and your stored state, not from anything the sender can spoof (tags, notes, claimed identity). Keeping it a pure function makes it unit-testable and auditable in isolation.

How it works

After a gift wrap is unwrapped and the slate parsed, decide() is called with an IngestContext (the parsed slate, amount, sender npub, any stored metadata for that slate, whether the sender is a contact, your accept policy, and whether requests are allowed). It returns one of:

DecisionWhenEffect
AutoReceiveA new payment (Standard-1) your policy lets inBuild the reply leg automatically
SurfaceIncomingA new payment under Contacts/Ask policyShow it for you to accept
FinalizePostA reply (Standard-2 / Invoice-2) that matches a pending tx and the right counterpartyFinalize and broadcast
SurfaceRequestA request for you to pay (Invoice-1)Show it for explicit approval; never auto-paid
Drop(reason)Anything elseDiscard, log the reason

The invariants that must never be weakened:

  • Invoice-1 (someone asking you to pay) is never auto-paid. It can only become SurfaceRequest, which requires you to hold-to-approve.
  • A reply only finalizes if it matches your pending transaction and the sender equals the stored counterparty npub. A Standard-2 from the wrong key, or with no matching send, is dropped.
  • Zero-amount and already-seen slates are dropped (anti-noise, anti-replay).
  • Crash tolerance: replies are also accepted when the local tx is still in Created/SendFailed (not yet flipped to AwaitingS2), because a send can crash between building and recording, but still only from the expected counterparty.

Reference

In goblin/src/nostr/ingest.rs:

  • IngestDecision enum: AutoReceive, SurfaceIncoming, FinalizePost, SurfaceRequest, Drop(&'static str).
  • IngestContext struct: parsed slate, amount, sender npub, stored meta, is_contact, accept policy, allow_requests.
  • decide(ctx) -> IngestDecision: the whole policy; covers Standard-1/2 and Invoice-1/2.
  • Accept policies (Everyone / Contacts / Ask) come from config.

References

  • Slate states and direction: Storage, config & types (NostrSendStatus, NostrTxDirection).
  • How decisions become actions: The payment flow.
  • The policy is exercised by the live nostr_e2e round-trip tests in goblin/tests/nostr_e2e.rs.

Storage, config & types

Summary. Goblin keeps a small per-wallet archive of Nostr metadata (transaction context, contacts, requests, processed-event markers) in an embedded key-value store, and a per-wallet config file for relay list, accept policy, and timeouts. These join to the GRIM wallet’s own transaction log to produce the Activity feed.

Motivation

Grin’s wallet log knows about transactions; it knows nothing about who you paid by username, the note you attached, or which request is still pending. Goblin needs a side-archive for that nostr-shaped context, plus a record of which events it has already processed (so it doesn’t replay them), all scoped to the wallet so nothing leaks between wallets.

How it works

The metadata store

A per-wallet rkv (SafeMode/LMDB) archive at wallet_data/nostr.rkv, holding:

  • tx_meta: nostr context for a slate (counterparty npub, direction, note, status, the gift-wrap/rumor event ids, timestamps), keyed by slate id and joined to the GRIM tx log.
  • contacts: people you’ve paid or saved (petname, nip05 + when last verified, DM relays, avatar hue, a blocked flag, and an unknown flag for keys auto-added from an incoming payment).
  • requests: incoming/outgoing payment requests by rumor id.
  • processed: event/rumor ids already handled, with slate state, pruned after 30 days (replay + dedup guard).

Per-wallet config

A nostr.toml (NostrConfig) holding: enabled, relays override, accept_from (Everyone default / Contacts / Ask), nip05_server (your name authority, which also yields home_domain() for federation), expiry_secs (auto-cancel an unanswered payment, default 24 h), cancel_grace_secs (how long before the cancel button appears, default 10 min), and allow_incoming_requests (opt-out of Invoice-1, advertised in your kind 0).

The types

types.rs defines the vocabulary the rest of the layer speaks:

  • NostrTxDirection: Sent, Received, RequestedByUs, RequestedOfUs.
  • NostrSendStatus: the slate state machine: Created, AwaitingS2, RepliedS2, AwaitingI2, PaidAwaitingFinalize, Finalized, SendFailed, Cancelled, …
  • TxNostrMeta, Contact, PaymentRequest, RequestStatus, CancelOutcome.

Reference

  • Store: goblin/src/nostr/store.rs: rkv SafeMode, the named databases above, 30-day TTL on processed. (Note: the env is opened with extra capacity so reopening a full set of DBs doesn’t panic, a fix recorded in the wallet history.)
  • Config: goblin/src/nostr/config.rs: AcceptPolicy, NostrConfig, load()/save(), home_domain().
  • Types: goblin/src/nostr/types.rs: directions, statuses, TxNostrMeta, Contact, PaymentRequest.

References

  • How statuses drive acceptance: Ingest policy.
  • How tx_meta + the GRIM log become the feed: Send & request and goblin/src/gui/views/goblin/data.rs.

Relays

Summary. Relays are the public servers that carry Goblin’s encrypted messages. Goblin ships a candidate pool of vetted relays, verifies each one locally before use, advertises a short DM-relay list (NIP-17 kind 10050) so others know where to reach you, and lets you add, remove, and add-custom relays. Which set the wallet starts from depends on the Tor routing mode; the project’s own relay.floonet.dev is included by default in both. When Tor routing is on, every relay, including the primary, is reached over a Tor exit to its clearnet host.

Motivation

Nostr has no central server: reachability depends on sender and receiver sharing at least one relay. So a wallet must (a) start with good defaults, (b) publish where it listens, and (c) let advanced users or self-hosters point at their own relays. Keeping the DM-relay list small is deliberate: NIP-17 guidance is to advertise only a couple, both to limit metadata and to make delivery predictable. And because a bad relay can silently drop a payment message, every candidate is verified by the wallet itself before it’s trusted with one.

How it works

  • The set depends on the transport. relay.floonet.dev (the project’s own Floonet relay, the floonet-strfry package running stock strfry with a write policy restricting stored kinds to the handful Goblin needs) is included by default in both modes. Around it:
    • On Tor, the wallet uses a fixed default relay set of Tor-exit-friendly relays (relay.floonet.dev, plus known Tor-friendly relays such as relay.nostr.net and offchain.pub). relay.damus.io and nos.lol are excluded because they refuse connections from Tor exit nodes.
    • On clearnet, the wallet draws a per-identity random healthy subset from the candidate pool, plus relay.floonet.dev by default. A random subset spreads load and avoids every clearnet wallet clustering on the same handful of servers.
  • Your choices are remembered per wallet, and per transport. Any relay you add or remove, including the default relay.floonet.dev, is saved for this wallet, and saved separately for Tor and for clearnet, so flipping Tor routing restores the list you last used in that mode. These choices survive app updates.
  • Advertising reachability. Your wallet publishes a kind 10050 DM-relay list (capped at 3) so a sender’s wallet knows which relays to deliver your payment to. The same event carries an encryption tag advertising the wallet’s NIP-44 v3 capability. The list is also fanned out, publish-only, to the pool’s discovery indexers so a payer who shares no relay with you can still find your inbox list. nprofile shares carry relay hints too, so a fresh recipient is reachable without any lookup.
  • Selection is sticky. The advertised set is picked once and persisted; there is no timer rotation, because churning a kind 10050 list breaks payers’ cached routing.
  • Editing. Settings → Nostr Relays shows your list and lets you add, remove, and add a custom wss://… relay (URLs are normalized: a bare host gets wss://); any relay can be removed, the default relay.floonet.dev included. “Save & reconnect” rewrites your kind 10050 and restarts the service on the new set.
Screenshot: Settings → Nostr Relays editor (list + add field + Save & reconnect), dark theme.

Note on the UI: the relay editor lives in the Identity section of Settings, labelled “Nostr Relays” (it sits just above Name authority), so it’s clear these are Nostr relays, distinct from the Grin Node connection under Wallet.

The candidate pool

The pool is a maintained list of vetted public relays, fetched from the project’s published pool file over Tor, cached on disk, and refreshed when the cache is older than 7 days. A pinned copy is compiled into the app, byte-for-byte the published contents, so first-run and offline behave exactly like a fresh fetch.

Each entry carries:

  • Roles: dm (eligible to carry gift-wrapped payments) and/or discovery (an indexer that only ever receives the public identity events, never a wrap).
  • A vetted date: vetted entries are weighted 3:1 when the advertised set is drawn.

Some entries are clearnet-only: relays that refuse connections from Tor exit nodes (relay.primal.net, nos.lol, relay.damus.io) are in the pool for wallets running on clearnet, but are never drawn while Tor routing is on.

An earlier pool schema also carried a per-relay onion field for a pinned onion service; build134 dropped it along with the onion-dialing path (see The relay’s Tor exit path), so current pool entries carry neither an onion nor a co-located exit field.

A pool relay is only ever used after passing a NIP-11 gate, checked lazily right before use (results cached for 24 hours): it must accept messages of at least 128 KiB (a worst-case payment wrap is ~66 KB on the wire), must not require payment, AUTH, or restricted writes, and must tolerate NIP-59’s up-to-2-day backdated timestamps. The NIP-11 fetch itself runs over Tor.

The fetched pool file is validated locally (schema version, entry caps) and can only raise the message-size floor, never lower it; and since every relay is still individually gated by your own wallet’s probe, a broken or hostile pool file degrades to the pinned defaults rather than being trusted.

Relay authentication (NIP-42)

The wallet does opportunistic NIP-42 relay auth automatically: when a relay asks a connected client to authenticate, the wallet signs the challenge and answers. It is free, invisible, and never a paywall, there is nothing to buy and nothing to configure. This is distinct from the pool gate above, which still refuses any relay that requires payment or restricted writes; opportunistic auth simply satisfies a relay that offers it, which lets a payment-only relay recognize and prioritize a real wallet without turning anyone away.

Floonet’s payment-retention guarantee

relay.floonet.dev (and other Floonet relays) guarantee retention of payment messages: a gift-wrapped payment cannot be prematurely deleted out from under a recipient who has not yet come online to fetch it. That guarantee is why the floonet relay ships as the default in every wallet’s list. Floonet relays will soon also deliver a payment message only to its intended recipient, so a wrap is not served to anyone else who happens to query the relay.

Reference

  • goblin/src/nostr/pool.rs: RelayPool / PoolRelay, PINNED_POOL, load() / refresh_if_stale(), the NIP-11 gate (nip11_pass(), probe()), weighted_order(), usable_discovery_relays(), MIN_MESSAGE_LENGTH = 131072.
  • goblin/src/nostr/relays.rs: DEFAULT_RELAYS, MAX_DM_RELAYS = 3, normalize_relay_url(); default name authority constants (HOME_NIP05_DOMAIN, DEFAULT_NIP05_SERVER).
  • Advertised-set selection and the discovery fan-out: ensure_advertised_set() and publish_identity() in goblin/src/nostr/client.rs.
  • Editor + “Save & reconnect”: the relays page in goblin/src/gui/views/goblin/mod.rs (SettingsPage::Relays, relay_summary()); the row that opens it is the Nostr Relays entry in the Identity card.
  • Relay transport: every socket runs over Tor.

References

Tor in Goblin

Summary. When Tor routing is on, Goblin sends every Nostr relay socket and every HTTP request (names, price feed, avatars) through Tor, embedded in-process with arti (Tor written in Rust), the same engine our sister wallet GRIM already ships. Every relay, including the default money-path relay, is reached over a Tor exit to its ordinary clearnet host; there is no onion hop anywhere in the current design. The relay and every network observer see a Tor exit address, never your phone’s IP. The division of labor is the whole idea: Tor hides your network location from the relay; the relay and the Nostr protocol hide everything else. Tor routing is a per-wallet setting (see below), not a permanent fixture. The Grin node connection stays direct in either mode: public chain data, where liveness matters more than anonymity.

Tor routing is a per-wallet setting

Whether the wallet’s Nostr traffic rides Tor is a switch you own, remembered per wallet and kept across app updates.

  • Where. Settings → Privacy → Tor routing, a single On/Off row with a large purple-and-yellow switch. The full Network privacy screen breaks down exactly what rides which path (payments, usernames, price) and carries the “Route through Tor” control with the caption “Hide your IP from relays.”
  • Defaults. A wallet you update keeps Tor on, exactly as before. A brand-new wallet defaults to Tor off (clearnet) and you choose during onboarding’s Network privacy step.
  • With Tor off. The same Nostr traffic still goes out end-to-end encrypted, but direct from your device, so the relay (and your ISP) can see your IP address. The in-app copy says as much and suggests pairing clearnet mode with a VPN if hiding your IP still matters to you.
  • The Grin node is always direct. In both modes the node connection is clearnet by design; only Nostr traffic (payments, usernames, price) ever rides Tor. Turning Tor off changes nothing about how the node syncs or broadcasts.

The Network privacy presentation controls (blurred balance, private notifications) are separate and independent of this switch.

Motivation

Encryption hides what you say; it doesn’t hide that you’re saying it, to whom, and when. For an interactive Grin payment that distinction matters a lot, and it breaks down into pieces that are best solved by different tools:

  • Your network location (IP). A relay you connect to is, by definition, the one machine you open a socket to, so it structurally can’t hide your network identity by itself: it sees where your connection comes from. That is the one gap Tor exists to close, and Tor is the best tool in the world for exactly it. Every connection rides a Tor circuit out through an exit relay to the destination’s ordinary hostname, so the relay (and every hop, and anyone watching the wire) sees a Tor exit address, never your phone.
  • Everything else is already handled above the transport, by the relay and the Nostr protocol Goblin runs on:
    • Content is end-to-end encrypted. A payment is a NIP-59 gift-wrap: the relay stores an opaque blob and nobody but the recipient can read it.
    • The sender is a throwaway, one-time key. The relay never sees who really sent a payment, only a per-message ephemeral key.
    • The timing, the one thing an interactive payment would otherwise leak, since the two-message ping-pong is a recognizable pattern, is shuffled by our own relay, which holds each message and releases it to the recipient on a short randomized delay (see below).

So Goblin needs Tor for one narrow, well-suited job, hide the IP from the relay, and its own relay for the rest. That focused split is why Goblin needs no heavier privacy machinery.

Why Tor

Tor is the right foundation to build a payments wallet’s privacy on. It is free, unmetered, and battle-tested, carries no token and nothing to bond, and has nothing that expires on a schedule.

It runs in-process on a phone (no separate program, no sidecar); it has the largest anonymity set of any deployed privacy network; it is light on the battery; and, measured where the user actually waits (sending, cold start), it is fast, because a payment flows through as fast as the circuit allows. GRIM has already proven the whole embedded path in production. The honest trade is covered under the threat model below.

How it works

The Tor client is a direct dependency, linked into the binary via arti-client, no sidecar process and no bundled binary to ship or sideload. Every connection Goblin makes, a Nostr relay websocket or an HTTP request, rides the same kind of circuit: a Tor-to-clearnet circuit out through a normal exit relay to the destination’s ordinary hostname. Tor resolves the hostname at the exit, so the device never emits a DNS query and never reveals its IP.

An earlier build (133) pinned the money-path relay behind a dedicated .onion address and dialed it directly over an onion circuit. Build 134 dropped that: the shared onion hop flapped under load and could stall a payment mid-handshake, so every relay, including relay.floonet.dev, is now reached the same way as any other, over a Tor exit. See The relay’s Tor exit path for that history.

The default relay set on Tor is a fixed trio of Tor-exit-friendly relays: relay.floonet.dev, relay.nostr.net, and offchain.pub. (relay.damus.io and nos.lol refuse connections from Tor exit nodes, so they aren’t viable defaults under a Tor transport.) You can edit the list freely; edits are remembered per wallet and per transport.

Whichever request it is, TLS is negotiated end to end against the destination hostname, so the exit relay and every hop see only ciphertext.

The component pages:

PageCoversFile
The embedded Tor clientBootstrapping arti in-process, readiness, the health/rebuild loop, mobilesrc/tor/
The relay’s Tor exit pathDialing the relay over a Tor exit; the retired onion servicesrc/tor/, pool.rs
Name resolution under TorHow every hostname resolves at the Tor exit without a device-side DNS query(arti)
Relay traffic over TorThe websocket transport for the relay pool, over a Tor DataStreamtransport
HTTP over TorHTTP requests over a Tor exit to a clearnet hostmod.rs

Timing privacy: the relay does it

Tor is low-latency by design and does not shuffle message timing, a payment flows through as fast as the circuit allows. That leaves one property to provide elsewhere: timing unlinkability, so that even an observer near both ends can’t match “this sender uploaded at 10:01:03” to “that recipient downloaded at 10:01:04.” Goblin provides it in the one place it fully controls, the relay, which holds each incoming gift-wrap and releases it to the recipient after a short randomized (Poisson) delay. The mixing happens on the single server we operate, unmetered and always on.

The elegant part is that this costs the user nothing they can see. The sender’s on-screen “Sent” clears the moment the relay confirms it holds the message, not when the recipient receives it, and delivery to the recipient is already asynchronous and invisible (they may be offline for hours). The randomized delay lands entirely inside that already-invisible gap. It also stacks on a fuzz the wallet already applies: NIP-59 backdates every gift-wrap’s timestamp by a random offset of up to two days, so even the timestamps on the wire are decorrelated from real send time.

What Tor covers, and what covers the rest

The realistic adversary is the relay operator, ISPs, near-endpoint observers, and chain analysts. Each kind of leak has an owner:

LeakWho covers it
Network location (your IP, as seen by the relay and on-path observers)Tor: a Tor-exit circuit shows the relay a Tor address, never your phone.
Timing (matching send-time to receive-time)The relay: the randomized release delay above, plus NIP-59 timestamp backdating already in the wallet.
Content (the message plaintext)The protocol: NIP-44 encryption inside a NIP-59 gift-wrap; the relay stores an opaque blob.
Sender identityThe protocol: a throwaway one-time key per message; the relay never sees the real sender.
Message sizeThe protocol: NIP-44 padding, and gift-wraps are already near-uniform at payments volume.

The one honest limitation. Tor states plainly that it does not defend against a global passive adversary who can watch the entire internet at once, an attacker who can see both ends of a circuit. A single relay plus Tor does not defend against that adversary either. That adversary is out of scope for a low-value Grin payments wallet, and it is not the threat this wallet realistically faces. For the adversary that actually exists, every level above is covered.

What goes over Tor, and what doesn’t

The paths below describe a wallet with Tor routing on. With Tor off, every row marked Tor instead goes direct (clearnet, still end-to-end encrypted, but the relay and your ISP see your IP); DNS is then a normal device lookup; and the Grin node row is unchanged, because it is always direct.

TrafficPath (Tor routing on)
Nostr relay sockets (payments + identity events)Tor: a Tor-exit circuit to the relay’s clearnet host.
NIP-05 name lookups + registrationTor: a Tor-exit circuit to the name authority’s clearnet host.
Price feed, avatars, relay-pool + NIP-11 probes, in-app update checkTor: out through a normal exit relay to the clearnet host (Tor resolves the name at the exit).
DNSNone on the device: Tor resolves every hostname at its exit. There is never a clearnet lookup.
Grin node connection (sync, broadcast)Direct, always: public chain data, not tied to your identity. The privacy budget is spent on the money path; chain sync favors liveness over anonymity, and Tor-wrapping it would buy nothing but latency.

References

The embedded Tor client

Summary. Goblin links Tor directly with arti (Tor written in Rust) and runs one process-lifetime client, copied almost verbatim from our sister wallet GRIM’s proven engine. It bootstraps at app launch, it’s gated on a real end-to-end readiness signal before the UI ever shows “Connected,” it’s health-checked for its whole life, and a dead circuit is rebuilt automatically. Goblin only ever dials over Tor — it never hosts a service — which makes it simpler than GRIM, which also hosts an onion to receive.

Motivation

Goblin has to hide a phone’s IP from the relays it talks to, and it has to do that on desktop and on a phone without shipping a separate helper process. Tor, embedded in-process, is built for exactly that. See Tor in Goblin for why it’s the right fit.

Rather than write our own Tor engine, Goblin copies GRIM’s. GRIM’s src/tor/ is a small, four-file engine already running in production on desktop and Android, so Goblin inherits a known-good implementation instead of paying for one twice. Two technical choices come along verbatim because GRIM already settled them:

  • arti 0.43 across the whole arti family (arti-client, tor-rtcompat, and the onion/crypto crates).
  • The native-tls Tor runtime (TokioNativeTlsRuntime), not rustls. This deliberately sidesteps the rustls crypto-provider (ring / aws-lc-rs) conflict. We take GRIM’s TLS path, which already settled it, and never re-open that wound.

How it works

The client is only started when the active wallet has Tor routing on; a wallet running in clearnet mode never bootstraps Tor at all. When it is on, warm_up() spawns a background task that bootstraps the Tor client on a dedicated runtime and keeps it alive for the life of the process:

  1. One bootstrap. Tor is a single bootstrap — one client, one connect. The bootstrap overlaps with app launch, so it’s mostly invisible; warming the circuit at launch hides even the first-send edge.
  2. Exit dialing. Once bootstrapped, the client opens Tor-exit circuits to the relay pool’s clearnet hosts, the name authority, and the small background lookups. Goblin only connects out; it never publishes a service of its own. (An earlier build also dialed a pinned relay .onion directly; that path was dropped in build134, see The relay’s Tor exit path.)
  3. Readiness gate. The UI refuses to show “Connected” until the transport is genuinely live: arti has bootstrapped, the Tor circuit is up, and a required relay is actually subscribed on it. A pipe that opened but can’t yet deliver never latches the UI green.
  4. Health and rebuild. A live circuit is watched, and a circuit that dies is torn down and rebuilt automatically. The wallet’s existing “the connection died, bring it back” logic and its background/foreground handling map cleanly onto Tor circuits.

Readiness, in detail. The honest “carrying traffic on the current connection” signal is load-bearing. warm_up() starts the client idempotently; a cheap is_ready() (safe to poll every UI frame) says the client is up; and the authoritative transport_ready() is true only when a relay is connected and subscribed on the current circuit generation. A stale or half-open circuit can never falsely report “Connected over Tor.”

Identity. Circuits use fresh, ephemeral state; nothing about your Tor usage is persisted as a stable identity.

Battery. A persistent Tor circuit is cheap to keep alive: there is no continuous cover-traffic machinery to run and no per-hop delay work to perform, so always-listening costs little.

Mobile

Android is solved — GRIM already ships embedded Tor there and the recipe copies over. It comes down to two things: arti compiles into the app’s native library (the same .so the rest of the Rust already lives in — no separate process), and a few environment variables are set before the Rust runtime starts. The critical one is ARTI_FS_DISABLE_PERMISSION_CHECKS=true (GRIM sets it in MainActivity.java before loading native code): arti’s fs-mistrust layer normally refuses to start if its state directory has “too-open” Unix permissions, and Android’s app sandbox always trips that check — so without this flag Tor simply never boots on a phone. It’s a known one-line fix, not a research problem.

iOS should work — nothing about arti is Android-specific — but it is treated as unproven until we ship it, so it gets its own spike. Two things going in: iOS runs plain Tor without pluggable-transport bridges (the platform won’t let an app spawn the helper processes those need, which is fine for reaching the relay), and the same fs-mistrust / state-directory questions get answered against iOS’s sandbox.

Reference

In goblin/src/tor/ (copied from GRIM’s grim/src/tor/, four files: config.rs, mod.rs, tor.rs, types.rs):

  • warm_up(): idempotent background start; the bootstrap/dial/readiness/watch loop on a dedicated runtime.
  • is_ready() / transport_ready(): the readiness surface the Nostr client and the UI use; transport_ready() is relay-gated on the current circuit generation.
  • arti wiring: TorClient on the TokioNativeTlsRuntime, arti 0.43.
  • Android bring-up: native-lib link + ARTI_FS_DISABLE_PERMISSION_CHECKS=true set in MainActivity.java before native code loads.

References

The relay’s Tor exit path

Summary. Every relay Goblin talks to, including the default relay.floonet.dev, is reached over a Tor exit to its ordinary clearnet host: the wallet’s embedded Tor client builds a circuit out through a normal Tor exit relay and runs the usual hostname-validated TLS + websocket handshake against the relay’s public address. There is no onion hop. An earlier build briefly pinned the relay behind a dedicated .onion address; build134 dropped it after the shared onion flapped under load (see the historical note below). Reaching the relay this way still hides the wallet’s IP, and it is fast: a cold app start connects in a few seconds, and a funded payment finalizes in about eight.

Motivation

The relay is the one machine the wallet must open a socket to, so it is exactly the piece that can’t hide your network location on its own, that is Tor’s narrow job. Routing that connection through a Tor exit closes the gap without needing anything special from the relay operator: the relay sees a Tor exit address on the wire, never the wallet’s IP, and the relay itself needs no onion service, no pinned address, and no extra infrastructure. The one requirement on the relay side is mundane: it has to accept connections from Tor exit nodes, which is why relay.damus.io and nos.lol (both of which block Tor exits) aren’t usable defaults, and why the fixed Tor default set sticks to relays known to accept them (relay.floonet.dev, relay.nostr.net, offchain.pub).

How it works

  • No discovery step. Because there is no onion to look up, connecting to a relay needs nothing beyond its ordinary wss:// URL. Every relay in the relay pool is dialed the same way.
  • Dialing. The relay transport hands the relay’s hostname to arti, which builds a Tor circuit out through an exit relay to that host; the usual TLS + websocket handshake then runs over the resulting stream.
  • TLS is end to end. The stream carries the same hostname-validated TLS + websocket handshake as any other connection (SNI is the relay host, certificate checked). The exit relay and every hop in the circuit see only ciphertext.
  • No clearnet fallback. The wallet never dials a relay directly outside Tor. If the Tor circuit can’t connect, the wallet surfaces the failure rather than silently dropping to the clear net. That is safe because sending is guarded, a payment is only ever reported “Sent” once the relay confirms it holds the gift-wrap (see the payment flow), so a slow or failed connect makes the caller retry, never lose money.

The fast money path

Even without an onion hop, this is a fast connection: against the default relay, a cold app start connects in a few seconds, and a funded payment finalizes in about eight seconds end to end (measured on build134 with two funded wallets on independent mainnet nodes). The wait the user actually watches, publish the payment, wait for the relay to confirm it holds it, stays short.

Historical note: the retired onion service

An earlier build (133) pinned the money-path relay behind a dedicated .onion address and dialed it directly over a real onion circuit, with the relay operator running a system-Tor onion service in front of the relay’s websocket port. That path is retired. Under real load the shared onion hop flapped (WebSocket 1006 errors), and a drop mid-handshake could strand a payment after the first gift-wrap: the recipient got the incoming alert, but the sender’s side never confirmed. Build 134 removed the pinned .onion, the has-onion gate that had rejected an onion-less relay list, and the onion field from the relay-pool schema, in favor of the Tor-exit path described above. Relay operators no longer need to run an onion service for Goblin to reach them; see Run a relay for what running a relay actually requires today.

Reference

  • Dialing: goblin/src/tor/transport.rs implements the Nostr SDK’s WebSocketTransport trait; connect() hands the relay’s hostname to arti and runs tokio_tungstenite::client_async_tls over the resulting Tor stream.
  • goblin/src/nostr/pool.rs: PINNED_POOL (no onion or exit fields as of build134); goblin/src/nostr/relays.rs: DEFAULT_RELAYS.

References

Name resolution under Tor

Summary. The wallet runs no DNS resolver of its own. Every hostname Goblin needs, relay or otherwise, is handed to Tor by name, and Tor’s exit relay performs the DNS lookup and opens the connection. The device never emits a DNS query and never sees the resolved IP.

How it works

  • Every request is resolved at the Tor exit. Nostr relay sockets, NIP-05 lookups, the relay-pool refresh, price, and avatars are all handed to Tor by hostname. Tor carries the name to an exit relay, which performs the DNS lookup, then opens the connection. There is nothing to leak on the device.
  • No wallet-side resolver. Goblin ships no DNS-over-TLS/DNS-over-HTTPS resolver of its own: Tor’s exit-side resolution makes one unnecessary.

Historical note

An earlier build (133) pinned the money-path relay and the goblin.st name authority behind dedicated .onion addresses, which resolve inside Tor’s own distributed hash and need no DNS lookup at all. Build 134 dropped that pinned onion (see The relay’s Tor exit path), so every hostname, including those two, now takes the exit-resolution path above.

References

Relay traffic over Tor

Summary. Goblin gives the Nostr relay pool a custom websocket transport backed by its embedded Tor client. Every relay is dialed the same way: a Tor-exit circuit to its clearnet host, running the usual hostname-validated TLS + websocket handshake over the Tor-carried byte stream. Nothing above the byte transport can tell one relay’s connection from another’s.

Motivation

The nostr-sdk relay pool normally opens websockets directly. To put relay traffic on Tor without forking the SDK, Goblin implements the SDK’s WebSocketTransport trait with its own connector. This is the clean seam: the entire rest of the Nostr layer is unchanged; only how a socket is opened differs, the byte source underneath is a Tor DataStream.

How it works

For each relay URL the pool wants to connect to, the transport:

  1. Dials over Tor to the relay’s clearnet host. The relay’s hostname is handed to arti, which builds a circuit out through an exit relay; the TLS (for wss) + websocket handshake then runs over that stream. The device never resolves the name or reveals its IP. (An earlier build preferred a pinned .onion when the pool advertised one; build134 dropped the onion field and that fork entirely, see The relay’s Tor exit path.)
  2. Splits the socket into a sink (writes) and a stream (reads) for the pool.

Each stage is bounded by the pool’s connect timeout and timed in the logs, so connect cost can be attributed per relay. The result is an ordinary websocket from the SDK’s point of view; it just happens to traverse Tor.

Reference

In goblin/src/tor/transport.rs (the arti-backed transport, copied from GRIM):

  • The transport type implements nostr_relay_pool::transport::websocket::WebSocketTransport.
  • connect(): hands the relay’s hostname to arti for a Tor-exit circuit, then feeds tokio_tungstenite::client_async_tls(url, stream), the TLS + websocket wrap is unchanged from the earlier onion-preferring version.
  • The socket-split and frame-adapter helpers.

References

HTTP over Tor

Summary. Every HTTP request Goblin makes (NIP-05 name resolution and registration, the price feed, avatar fetches, the relay pool and its NIP-11 probes) goes through Tor via a Tor-exit circuit to the destination’s ordinary clearnet host. TLS is validated against the hostname, and HTTP/1.1 runs on top. There is no clearnet HTTP path from the device, and no onion hop for any of it.

Motivation

It would be easy to leave “just a name lookup” or “just the price” on the clear net even when the wallet is otherwise private. Goblin deliberately doesn’t: a name lookup reveals who you’re about to pay, and any clearnet request reveals your IP and ties you to the app. So whenever Tor routing is on the rule is simple and absolute, everything over Tor, with no side channel left on the clear net to audit for. (When a wallet is in clearnet mode these same requests go direct, the deliberate trade the user chose.)

How it works

The shared HTTP helper waits for the Tor client to be ready (starting it lazily if needed), then for each redirect hop:

  1. Every request rides a Tor-exit circuit. The hostname, whether it’s goblin.st, the money-path relay’s own host (in practice its NIP-11 probe), or the pool refresh/price/avatar hosts, is handed to Tor by hostname and carried out through a normal exit relay, which performs the DNS lookup. Neither the lookup nor the body ever touches the clear net from the device. (An earlier build dialed the name authority at a pinned .onion; build134 dropped that in favor of this single path, see The relay’s Tor exit path.)
  2. TLS. For https the Tor byte stream is wrapped in TLS and validated against the hostname, so a lying resolver or a hostile hop cannot man-in-the-middle the request.
  3. HTTP/1.1 via hyper: a fixed goblin-wallet user agent, the caller’s method, headers, and body, with a generous per-hop budget.
  4. Redirects are followed like a browser, up to a small hop cap: 301/302/303 turn into a bodiless GET, 307/308 replay the method and body.

Logs never contain full URLs, only hosts. A string-bodied convenience wrapper sits on top of the byte-level helper.

Connections are reused. HTTP over Tor keeps circuits warm and reuses connections (keep-alive) instead of a fresh handshake per request, which is what makes repeated price and username lookups cheap. The price feed in particular paints instantly: the last fetched rate (if recent) shows on the very first frame, and a live fetch fires the moment Tor is ready rather than waiting for the balance screen.

Reference

  • http_request_bytes(method, url, body, headers) -> Option<(u16, Vec<u8>)> and the String-bodied http_request(...): the HTTP chokepoint, routed through arti.
  • Per request: a Tor-exit circuit to the clearnet host, then a hostname-validated TLS wrap and one hyper HTTP/1.1 exchange.

References

The end-to-end payment flow

Summary. This page follows one payment from the moment you hold Send to the moment it settles on the Grin chain, through GRIM, Nostr, and Tor. It’s the single best way to see how the three pillars cooperate.

The cast

  • GRIM builds and finalizes the Grin slatepacks.
  • The Nostr protocol wraps each slatepack as an encrypted message.
  • The NostrService publishes and receives them.
  • Tor carries every byte, when Tor routing is on (the diagram below assumes it is; in clearnet mode the same legs go direct).
  • The ingest policy decides what each side does with what it receives.

Standard payment, step by step

Alice pays Bob 5 ツ by username.

ALICE                                                   BOB
  │ 1. resolve bob → npub (NIP-05, over Tor)
  │ 2. GRIM builds Standard-1 slatepack
  │ 3. gift-wrap (kind 1059) + publish ───┐
  │    to Bob's kind 10050 relays         │  over Tor
  │    over Tor                           └──────────────▶ 4. ingest: AutoReceive
  │                                                         5. GRIM builds Standard-2
  │ 7. ingest: FinalizePost  ◀──────────── gift-wrap ──────┘ 6. publish reply (over Tor)
  │ 8. GRIM finalizes the tx
  │ 9. broadcast to Grin node (direct)
  ▼ 10. both wallets see it confirm (10 blocks)
  1. Resolve. If you typed bob, the wallet resolves it to an npub via NIP-05, an HTTPS lookup that goes over Tor. (Paste an npub/nprofile and this is skipped; relay hints may come along for free.)
  2. Build leg 1. GRIM creates the Standard-1 slatepack for 5 ツ to Bob and records tx_meta (direction = Sent, status = Created).
  3. Wrap & send. The send pipeline builds a kind 14 rumor (preamble + slatepack + note), seals and gift-wraps it (NIP-59), and publishes the kind 1059 to your relays and Bob’s DM relays, all over Tor. Status → AwaitingS2. The send spinner completes as soon as a relay acknowledges the write, so the UI shows “Sent” promptly; a background read-back re-checks delivery without holding up the screen.
  4. Bob ingests. Bob’s wallet (even if just reconnected) pulls the gift wrap, unwraps it, parses the slate, and runs decide(). A new payment under the default policy → AutoReceive.
  5. Build leg 2. Bob’s GRIM builds the Standard-2 reply.
  6. Reply. Bob’s wallet gift-wraps and publishes it back to Alice’s relays, over Tor.
  7. Alice ingests the reply. Her wallet matches the Standard-2 to her pending tx and confirms it came from Bob’s npubFinalizePost.
  8. Finalize. Alice’s GRIM finalizes the transaction.
  9. Broadcast. The finalized tx is posted to the Grin node directly (public chain data; not over Tor).
  10. Confirm. Both wallets watch the chain; after the confirmation window the payment shows as settled.

Neither party pasted a slatepack, and neither needed the other online at the same instant: relays buffered the messages.

How the bytes travel

With Tor routing on, every message above rides Tor; the wraps themselves are NIP-44 encrypted (v3 when both wallets support it) either way. The primary relay, like every other relay, is then dialed over a Tor exit to its clearnet host, so the payment path never touches the clear net from the device, and it’s fast: the money-path relay connects in a few seconds even from a cold app start, and a funded payment finalizes in about eight seconds end to end.

Requests (invoice flow)

A request runs the same machinery with the roles inverted: you issue an Invoice-1 (“please pay me 5 ツ”), the payer’s wallet surfaces it for explicit approval (never auto-paid; see ingest policy), and on approval the Invoice-2/finalize legs complete. Declining or cancelling sends a void control message.

Payment proofs (on request)

Payments can include a native Grin payment proof when the payment request asks for one, off by default, shown on the review screen.

That is the whole rule. An ordinary person-to-person send carries no proof and is byte-identical to a send with the feature never involved. Proof mode is turned on for a single transaction only when the payment link that opened the review screen asks for it, by carrying the relevant parameters (a proof address, and optional order and notify hints). When it is on, the review screen shows a plain Payment proof: Included row so you can see it before you hold to send. The parsing is fail-closed: if any of those parameters is missing or malformed, the wallet drops proof mode and falls back to a normal proof-free payment rather than guessing. Requests you issue are unaffected; only a send whose link explicitly asks for a proof ever produces one.

Where it’s wired

  • UI dispatch: goblin/src/gui/views/goblin/send.rsWalletTask::NostrSend / NostrRequest / NostrPayRequest.
  • Task handling + finalize/broadcast: goblin/src/wallet/wallet.rs (the WalletTask::Nostr* arm, guarded wrappers nostr_receive / nostr_finalize_post / nostr_pay).
  • Wrap/unwrap + publish/subscribe: goblin/src/nostr/{protocol,client}.rs.
  • Accept/finalize decisions: goblin/src/nostr/ingest.rs.

References

  • The whole flow is exercised live by goblin/tests/nostr_e2e.rs (nip17_slatepack_roundtrip).
  • Slate stages (Standard-1/2, Invoice-1/2): Grin docs, https://docs.grin.mw.

Send & request, recipient search

Summary. The send flow is a small state machine (pick a recipient, enter an amount, review, hold to send) with a type-ahead recipient search that resolves usernames, verifies pasted keys against Nostr profiles, and gates unverified keys behind a confirmation. The same surface issues requests (invoices).

Motivation

Paying should feel like a chat app: start typing a name, see suggestions, tap, confirm. But pasted keys are dangerous (typos send money into the void), so the picker has to verify what you give it and warn when it can’t. And because Grin payments are interactive, the UI must clearly show progress while the two legs complete.

How it works

The flow (SendFlow) moves through stages: Recipient → Amount → Review → Sending → Success / Failed.

  • Recipient search. As you type, the wallet matches local contacts instantly and runs a debounced (~0.4 s) network lookup in parallel. Results render as tappable cards:
    • A name resolves via NIP-05 → a verified card (shown as the bare name, or name · domain for a foreign authority).
    • A pasted npub/hex/nprofile triggers a kind 0 profile fetch. A key with a published profile shows “✓ on nostr”; a key with no profile is shown as unverified.
    • Unverified keys are gated: tapping one asks “Pay an unverified key?” with Keep looking / Pay anyway. (Goblin’s own domain skips the gate; foreign domains don’t.)
  • Amount. A centered numpad (mobile) or typed field (desktop). Over-balance entry flashes red and shakes rather than silently failing.
  • Note. An optional memo, editable in a modal, travels in the message’s subject tag.
  • Review → hold to send. The review hero shows recipient, amount, fee and note; a hold-to-send gesture (a deliberate ~1.5 s press) confirms, and is hard to do by accident. This dispatches the payment.
  • Sending → Sent. The sending spinner completes as soon as the relay acknowledges the write (the gift wrap is on a relay), so “Sent” appears quickly instead of blocking on a full delivery re-check. The read-back delivery confirmation still runs, now in the background, and a genuine delivery failure still surfaces.
  • Sent once. The moment a payment is dispatched, the pay affordances that produced it, the send button and any pay QR, clear immediately. There is no window in which the same payment can be fired twice by an impatient second tap: once it’s sent, the control that sent it is gone.
  • QR / scan-to-pay. The recipient row and the home header offer a camera scanner; the home My Code QR encodes your own nprofile (npub + relay hints) so someone can scan to pay you and reach you with no lookup, with the black Goblin logo centred in it so it reads as clearly yours. The receive screen’s Share and Copy both hand over that same nprofile. Scanning a checkout code that carries an amount (for example from GoblinPay) fills the amount and note too, not just the recipient. See QR & camera.
  • Payment deep links. A goblin: or nostr: pay link (the two are byte-identical) opens the wallet straight to the review screen with the recipient, amount and note already filled in, ready to hold-to-send, on desktop (macOS included), Android, and via a scanned QR, which all take the same path. A link that carries only a recipient drops you into the prefilled recipient search instead. Whatever a link claims, the wallet re-parses the recipient and amount authoritatively before building the payment, so a malformed link degrades to “recipient only, enter the amount yourself” rather than doing anything surprising.

Requests reuse the Pay/Request screen: choose Request instead of Pay to issue an Invoice-1 to a contact (or broadcast a “requesting X ツ” code). Incoming requests appear as approve/decline cards; see Cancel & decline.

Batch invoices. A payment link or QR can ask the wallet to issue several invoices at once, up to 20 in one link. Instead of a prompt per invoice, the wallet shows a single approval that says how many invoices are being requested and for how much in total; one confirmation issues the whole batch. Anything a batch link gets wrong (or a count the wallet cannot honour directly) quietly degrades to the ordinary single-invoice flow.

The Activity feed and home “recent contacts” strip are built by joining the GRIM transaction log with nostr tx_meta (goblin/src/gui/views/goblin/data.rs). Each recent-activity row reads left-to-right the way a message list does: the note or counterparty on the left, ellipsised when it’s long, with the amount and a relative time stacked on the right. Opening a payment’s detail view shows the fuller picture, down to the seconds on its timestamp.

Screenshots: (1) recipient search with candidate cards, (2) numpad amount, (3) review hero with hold-to-send, (4) Activity feed, dark, 390×844.

Reference

In goblin/src/gui/views/goblin/send.rs:

  • SendFlow + Stage enum; Recipient, Candidate, LookupResult types.
  • Debounced lookup → NIP-05 resolve / kind 0 fetch; the unverified-key confirm gate.
  • request: bool switches Pay ↔ Request; scan via CameraContent + ScanTab.
  • Dispatch: WalletTask::NostrSend / NostrRequest (amount, recipient hex, note, relay hints).
  • Profile verification: NostrService::fetch_profile_blocking() (client.rs).
  • Feed/contacts model: goblin/src/gui/views/goblin/data.rs.

References

News on Home

Summary. The Home screen can show a single news card: the latest article published by the official Goblin news key. It sits between the Send/Receive actions and Recent Activity, is hidden when there is nothing to show, and always renders only the newest post. This page also covers how the news is published (a plain kind 30023 long-form article), so an operator can post their own.

What the user sees

When the wallet has cached a news post, Home renders a card with the article’s title and a short summary, spanning the full content width so it reads as a band rather than a chip. Any http(s) link in the summary is tappable and opens in the browser. No markdown is rendered in the panel, and there is no empty state: with no post cached, the card simply does not appear.

The panel is latest-only and language-aware. It shows exactly one article: the most recent one from the news key in the wallet’s own language, falling back to the newest English article when nothing is published in that language. An edit republished under the same identifier replaces it in place rather than stacking a second card.

The card is fetched from the wallet’s own relay set (which includes the money-path relay relay.floonet.dev) on the same subscription machinery as everything else, so it arrives over Tor like the rest of the wallet’s traffic. The wallet guards both the kind (30023) and the author (the news key), so a stray event on the news subscription cannot spoof the panel.

How it works

  • The news publisher is a fixed key compiled into the wallet (NEWS_NPUB in goblin/src/nostr/client.rs), currently npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely.
  • The wallet subscribes to that key’s kind 30023 (NIP-23 long-form) events on its relay set under a stable subscription id (goblin-news), so a reconnect replaces the subscription rather than piling up duplicates.
  • Each post is cached with its d identifier, created_at, title, a summary, and a detected language. The store dedupes newest-per-d and retains up to 18 cached events (raised from 8 in Build 158). Because one post published in all nine wallet languages is nine separate events, the old cap of 8 could evict a language before it was needed, and in practice dropped the English fallback; 18 leaves room for all nine languages of a post with headroom to spare.
  • The panel (news_latest) selects the newest article whose language matches the wallet’s locale (folded to its ISO-639-1 primary, so zh-CN matches zh), then falls back to the newest English article if there is none. A post’s language comes from its ["l", …] tag if present, otherwise a trailing [xx] marker in the title, otherwise it is treated as English. The summary is the summary tag when present, otherwise the first couple of lines of the article body flattened to plain text and capped to about two lines.

On desktop, Home now lays out to the full window width, so the news band and the rest of the Home content use the available space instead of a fixed narrow column.

Publishing the news (MOTD how-to)

The news card is just a standard Nostr long-form article, so publishing is ordinary Nostr with two rules: sign with the news key, and send it to the relay the wallets read.

  1. Sign with the news key. Only articles from the compiled-in news key render in the panel; anything else is ignored.
  2. Publish a kind 30023 long-form article (NIP-23) to wss://relay.floonet.dev. That is the money-path relay in the wallet’s default set, so a post there reaches wallets. (30023 is author-locked on Floonet relays: the news key must be an authorized author on the relay, which the flagship is configured for.)
  3. Add a title tag and a summary tag. The panel shows the title and the summary; without a summary tag it falls back to the first lines of the body. Keep the summary to roughly two lines.
  4. Put any link in the summary. http(s) URLs in the summary are tappable in the wallet; links in the article body are not surfaced in the card.
  5. To update or correct a post, republish under the same d identifier. Because the panel is latest-only and dedupes per d, an edit-in-place replaces the card instead of adding another. A brand-new d (with a newer timestamp) simply becomes the new latest.

Publishing in more than one language

Because the panel is language-aware, publish one article per language and let each wallet pick its own. The convention:

  • Mark the language. The preferred way is a ["l", "<code>", "ISO-639-1"] tag (a clean title, machine-read language). Alternatively, append a [xx] marker to the title (for example Welcome [de]). No tag and no marker means English.
  • One d per language. Use the base slug for English and slug-<lang> for every other language (for example welcome and welcome-de). That way an edit to the German article replaces the German card in place and never collides with the English one.
  • Date in ISO 8601. Where a title carries a date, write it as YYYY-MM-DD.

A wallet then shows the newest article in its own language, falling back to the newest English article when that language has none. Publishing only an English article is fine; every wallet will show it.

Caveat: verify it landed

Some Nostr clients (Jumble is one) can silently fail to deliver an event to a relay you just added to their relay list, reporting success while the write never reaches the relay. After publishing, verify the article is actually on relay.floonet.dev (query the relay for the news key’s kind 30023), and republish if it did not land.

Reference

In goblin/src/nostr/:

  • client.rs: NEWS_NPUB, NEWS_SUB, the news subscription, and handle_news (kind + author guard, newest-per-d cache).
  • types.rs: the cached NewsItem.
  • store.rs: save_news (dedupe newest-per-d) and news_latest.
  • gui/views/goblin/mod.rs: news_panel_ui (the Home card, tappable summary links).

References

The NIP-05 name authority

Summary. Usernames like alice@goblin.st come from a small, self-hostable service (goblin-nip05d) that implements NIP-05 resolution and NIP-98-authenticated registration. Goblin ships with goblin.st as the default authority, but it’s configurable: anyone can run their own and Goblin can point at it (federation). The standalone goblin-nip05d is the original minimal edition; the same name service is now bundled into the Floonet relay packages, which run it alongside a relay or on its own (see Run a name authority).

Motivation

npub1… keys are unreadable. NIP-05 maps a friendly name@domain to a key over plain HTTPS, but the registration side (who gets a name, how squatting is prevented, how you prove you own a key) is not specified by NIP-05. goblin-nip05d is Goblin’s answer: a tiny authority that hands out names, proves ownership with signed Nostr events (no passwords), and resists abuse, and which you can host yourself so Goblin isn’t dependent on one operator.

How it works

  • Resolution. A wallet resolving alice@goblin.st fetches https://goblin.st/.well-known/nostr.json?name=alice (over Tor, via a Tor-exit circuit to goblin.st’s clearnet host) and reads the pubkey (and any relay hints). A reverse lookup (name-by-pubkey) lets a wallet show the name for a key it only knows by npub.
  • Registration is keypair-authenticated. Claiming or releasing a name is a NIP-98-signed HTTP request: you prove control of the key, no account or password. The server enforces one active name per pubkey, a set of reserved names (and domain-label reservations), look-alike/homograph folding, a name length cap, and a change cooldown to stop churn/abuse. NIP-98 events are single-use within a freshness window (replay protection).
  • Transfer. Rotating your key can carry your name with it: the old key authorizes a transfer to the new pubkey, so you keep alice after rotation. Selling a name to someone else is a separate, payment-verified flow: see Name marketplace.
  • Federation. The authority is just a host. Settings → Username is the single home for everything name-related, and it leads with the name authority: the page opens with the authority you are pointed at, and claim or release sits below it. Leave it as the default goblin.st, pick another instance from the known list, or free-type a custom server’s URL. Point it at another instance and bare names then resolve against that domain, and foreign name@otherdomain identifiers resolve against their domain. Goblin only auto-trusts its own domain’s names; others pass through the unverified-key gate. The Username page carries a one-line authority note with a Learn more link that opens this chapter.
Screenshot: Settings → Username, name authority on top (value goblin.st, custom field) with the claim / release panel below, dark.

Reference

  • Client side (goblin/src/nostr/nip05.rs): split_identifier() (parses user@domain or a bare name; a leading @ the user happens to type is stripped), is_valid_hostname(), resolve(), name_by_pubkey() (reverse), verify(), Nip05Check (Verified / Mismatch / Unreachable); set_home_domain() / home_domain(); defaults HOME_NIP05_DOMAIN = "goblin.st", DEFAULT_NIP05_SERVER.
  • Server side (goblin-nip05d/, a sibling Axum + SQLite crate): the .well-known/nostr.json endpoint, /api/v1 name availability / register / release / transfer / by-pubkey, NIP-98 auth, reserved names, cooldown. It bundles a stock strfry relay write-policy and is deployed at goblin.st but designed to be self-hosted.
  • UI: the Username settings page (SettingsPage::Username, username_ui()) is the single home for claim, release, and choosing the authority (known list plus a free-typed custom server); the old inline name-authority editor and the main-settings claim card were removed. Claim/transfer flows in goblin/src/gui/views/goblin/mod.rs (ClaimState); availability mapped to friendly copy via availability_feedback(). The one-line authority note links out via goblin.username.learn_more to this chapter.

References

Name marketplace

Summary. A name like alice@goblin.st can be sold. The seller lists it on magick.market for a price in Grin, the buyer pays the seller directly, wallet to wallet, and the name authority moves the name to the buyer’s identity after checking the payment on chain. Nobody in the middle ever holds the money: there is no escrow, no custody, and no platform balance. The marketplace is a shop window and a set of tools, not a bank.

Motivation

Names are scarce (one active name per identity, first come first served), so a good one is worth something. Before this feature the only way to give a name up was to release it, which throws it open to whoever registers fastest, so there was no safe way to hand it to a specific person for payment. A sale needs two guarantees at once: the buyer must know that paying gets them the name, and the seller must know the name only moves once they have been paid. Grin payment proofs plus a signed sale offer give both, without any third party touching the funds.

How it works

Selling. Sign in to magick.market with the identity that holds the name. Listing it for sale asks for three things: the price in Grin, the buyer’s npub (v1 sales are targeted: you name your buyer up front, there are no open “anyone can buy” offers), and a fresh one-time proof address from your wallet, used for this sale and nothing else. Magick signs the offer with your identity key and lodges it at the name’s authority. Until the buyer completes the sale you can revoke the offer at any time.

Buying. Agree the deal with the seller first, off to the side, because the offer is written for your exact key and price. Then pay the seller the exact amount straight from your wallet, with payment proof turned on for that send. When the payment has settled, submit the proof on magick.market. That is the whole buyer flow: pay, then hand over the receipt.

Verification, then reassignment. The authority checks the proof against the signed offer: the exact amount, paid to the offer’s one-time address, settled on chain with at least 10 confirmations, and never used for any other sale. If everything matches, it reassigns the name to the buyer’s identity in one atomic step. From then on alice@goblin.st resolves to the buyer.

Person to person, no middleman. The buyer’s Grin goes from the buyer’s wallet to the seller’s wallet and nowhere else. Neither magick.market nor the authority ever holds, forwards, or even sees the funds in flight; the authority only reads the chain to confirm the payment happened. There is no escrow to trust and no platform account to freeze.

Fair to both sides. The seller signs and lodges the offer before the buyer pays, so once the payment is on chain the transfer needs no further cooperation from the seller: they cannot take the money and keep the name. And the name only ever moves against on-chain proof of the exact agreed payment to the seller’s own address: the buyer cannot get the name without paying.

Operators opt in. Name sales are off by default on every authority. Each operator decides whether to enable them, and doing so needs nothing more than a flag and read-only access to a Grin node (to confirm payments; the authority never runs a wallet). goblin.st has them enabled (GOBLIN_ALLOW_TRANSFERS with a read-only Grin node URL); the authority bundled with the Floonet relay uses FLOONET_TRANSFERS. If an authority has not opted in, names on that domain simply cannot be sold there.

Before you sell or buy, in plain words.

  • Agree the sale with your buyer first. v1 offers name one specific buyer; there is no browsing-and-buying by strangers.
  • Back up before you sell. Selling a name is like deleting it from your side: once it moves, it is gone from your identity for good, exactly as if you had released it. There is no undo and the authority cannot give it back.
  • Buyers: pay the exact amount, with proof on. A payment without a proof, or for the wrong amount, cannot complete the transfer. (If you do mispay, the funds are with the seller; the standard fix is for the seller to relist at the amount actually paid so your existing proof matches.)
  • Buyers: one name per identity. If the identity you are buying with already holds a name, release that name first (or buy with a different identity in your wallet); the offer and your proof stay valid while you do.
  • Keep the offer and your proof. Together they are your receipt: the seller’s signed promise and your on-chain payment.
Screenshots: magick.market sell-name listing form (price, buyer npub, proof address), Goblin send with proof enabled for the exact amount, submit-proof / claim step, dark, 390×844.

Reference

  • Authority side (goblin-nip05d/): the transfer routes are mounted only when transfers are enabled, otherwise they 404. POST /api/v1/transfer/offer lodges the seller-signed offer (a kind-3402 Nostr event binding name, buyer pubkey, price in nanogrin, one-time proof address, and expiry), GET /api/v1/transfer/offer/{id} reads an offer and its status, DELETE revokes it (seller-signed), and POST /api/v1/transfer/claim submits the buyer’s payment proof. Claims are authenticated with NIP-98 by the buyer’s key, which must match the offer’s p tag. The authority verifies the proof’s two signatures, confirms the kernel on chain via a read-only node foreign API (get_kernel + get_tip), enforces exact amount and address, keeps every consumed kernel unique forever (no proof reuse), and executes the reassignment as a single SQLite transaction. The full contract is the Goblin Name Transfer Protocol v1 spec; the goblin-nip05d README documents every endpoint and error.
  • Configuration: GOBLIN_ALLOW_TRANSFERS (default false), GOBLIN_GRIN_NODE_URL (required when on), GOBLIN_TRANSFER_MIN_CONF (default 10), GOBLIN_TRANSFER_MAX_OFFER_TTL (default 30 days), GOBLIN_TRANSFER_CLAIM_GRACE (default 1 day). The Floonet-bundled authority takes the identical set under the FLOONET_ prefix (FLOONET_TRANSFERS, FLOONET_GRIN_NODE_URL, …).
  • Marketplace side (magick.market): the public listing is an advert (a kind-30402 product event); the binding offer is minted per buyer, and the claim is submitted from the buyer’s signed-in session.

References

Multiple identities

Summary. One wallet can hold several identities at once (up to 8). They all share the same Grin seed and the same balance: an identity is a front door, not a separate pot of money. While the wallet is open every identity listens at the same time, so you can receive on all of them simultaneously, and switching which one you present as is instant, with no password and no re-sync.

Motivation

One person is often several “yous”. A seller on magick.market taking payments is not the same face as the personal identity that pays friends, and neither should be linkable to the other. Before this feature the choices were rotation (which retires the old identity) or a second wallet (which splits your funds). Holding many identities in one wallet keeps a single balance while each context gets its own name, its own payment history, and no public connection to the rest.

How it works

One wallet, many identities, one balance. The wallet still has exactly one recovery phrase and one balance. Identities are how people reach you and how you sign, not where the money lives, so adding or removing an identity never moves or risks funds.

All listening at once. Unlocking the wallet unlocks every held identity together, and one relay subscription listens for payments to all of them simultaneously. A payment to your seller identity lands even while you are sending as your personal one, and the transaction details show which identity was paid.

Instant switching. Switching just changes which identity you present and send as. No password, no syncing, no waiting: the keys were already unlocked when the wallet opened, so the switch is a pointer move.

Adding an identity. One add sheet, two buttons. Generate creates a brand-new anonymous key, unlinkable to your seed and to your other identities. Import brings an existing identity in instead: an identity .backup file (name and history included) or a pasted nsec. Cancel sits beneath both.

The manage sheet. Each identity in the switcher has a pencil on its row that opens a manage sheet for that one identity; tagging and deleting live there. The sheet is a focused modal: while it is open the list behind it is locked, so a stray tap can’t switch you or touch a different identity.

Private tags. From the manage sheet you can name any identity with a device-only label (“shop”, “friends”). The tag is never published anywhere, so it can be as honest as you like, and it rides inside the encrypted identity backup, so restoring the backup brings the label back too. Wherever identities are listed, the private tag is shown first, then the claimed name, then the npub.

One backup covers them all. As of Build 158 the wallet’s full backup (Settings → Advanced, under Advanced nostr settings, captioned “Contains your wallet and all identities.”) seals your Grin seed and every held identity into one encrypted .backup file, with the active one marked, so a single restore brings the whole set back at once. See Identity backup & restore. (Importing a single-identity .backup from the add sheet, above, still works for bringing in just one.)

Deleting. Delete sits at the bottom of the manage sheet and is double-gated: first a warning that the identity will be permanently removed, with a reminder to back it up first, then your wallet password. Deleting an identity you have not backed up is unrecoverable; the key is gone for good. Two guard rails: you cannot delete your last identity, and deleting the active one switches you to another identity first. Your funds are not at risk either way, because they live on the seed, not the identity.

Security model, in plain words. Each identity’s key is encrypted on your device with your wallet password, exactly like the single identity always was. Keys are only unlocked in memory while the wallet is open; close the wallet and every identity locks again.

Screenshots: Identity switcher list with tags, Add identity (generate, with import toggle), Manage sheet, Delete warning with backup reminder, dark, 390×844.

Reference

  • The held-identity index (which identities the wallet holds, display order, which is active; carries no secrets): HeldIdentities / HeldEntry in goblin/src/nostr/identities.rs, persisted as nostr/identities.json with a cap of MAX_IDENTITIES = 8. Pre-feature wallets migrate automatically: the existing identity.json is adopted as identity #1 and never rewritten, so an older build still opens the wallet cleanly.
  • Each held identity is a full NostrIdentity with its own NIP-49 ncryptsec (identities/<hex>/identity.json); the local tag is the private_tag field in goblin/src/nostr/identity.rs.
  • All-at-once listening: the service keeps every held identity of the open wallet live for the session and names all their pubkeys in a single gift-wrap subscription; an incoming wrap is opened by whichever held key it was addressed to. See goblin/src/nostr/client.rs.
  • Switcher, add, and manage-sheet (tag / delete) UI: IdentitySwitchState in goblin/src/gui/views/goblin/mod.rs (step-1 confirm_delete, then a password-gated pending action).

References

Sign in with Goblin

Summary. Sites that speak Goblin, starting with magick.market, offer Log in with Goblin in their login dialog. On the same device a button opens the Goblin app; from another device you reveal a QR and scan it with the wallet. The wallet shows you who is asking and which identity will sign, you confirm with your wallet password, and it signs a one-time login request. Your key never leaves the wallet, and the site never gains the power to act as you.

Motivation

Signing in to a website with a Nostr identity usually means handing your key to a browser extension, or pasting an nsec into the site itself. Both put the key somewhere it does not belong. The wallet already holds your identities behind your password, so it is the right place to approve a login: the site asks, the wallet shows you exactly what is being asked, and only a signature comes back out.

How it works

Starting from the site. magick.market’s login dialog offers Log in with Goblin. If the wallet is on the same device, a button opens the Goblin app directly. If it is on another device (your wallet on your phone, the site on your laptop), reveal the QR instead and scan it with the wallet’s camera.

The approval screen. The wallet opens a single approval modal that shows who is asking (the site’s domain) and which identity will sign: your private tag or claimed name, with the npub beneath it so you can anchor on the key itself. If you hold several identities you pick which one to log in as. Then you enter your wallet password, and the wallet signs the login request.

What gets signed. The signature covers a one-time challenge from the site and nothing else. It proves to the site that you control the identity’s key, right now, for this login. It is not a session key, not a delegation, and it cannot be replayed: each request is single-use and a pending approval expires after about two minutes if you leave it untouched. Only one login request is considered at a time; while one is on screen, new ones are ignored.

What the site gets, and does not get. The key never leaves the wallet. The site receives a signature, not a secret, so it cannot act as you. A plain login grants no signing power: anything that would publish as you (listing a name for sale, signing an offer) still asks for your key separately at that moment. If you want a site to sign low-risk actions for you without asking each time, that is a separate, explicit step, and money always still asks. See Authorize Sessions.

Completing across devices. When you opened the wallet from a same-device button, a successful sign-in hands you back to the calling app. A QR sign-in is the cross-device case: your phone signed, but the site is on your laptop, so there is nowhere on the phone to return to. The wallet posts the signed login to the site’s callback and simply stays put; the browser is watching for that and completes the sign-in on its own. You do not switch apps or copy anything back by hand.

Declining. Cancel the modal, or simply let it expire, and nothing is sent. The site’s login attempt just fails.

Screenshots: magick.market login dialog with Log in with Goblin (button + QR), wallet approval modal showing domain, identity picker and password field, dark, 390×844.

Reference

  • The login URI (goblin:login?c=<64-hex challenge>&d=<domain>&cb=<https callback>; nostr:login is the byte-identical QR form) is parsed fail-closed in goblin/src/nostr/loginuri.rs, mirroring the pay-URI parser. A login-shaped URI that fails validation is rejected whole: it never reaches the pay path and never opens an approval modal.
  • On approval the wallet signs a kind-22242 event (NIP-42 client auth: empty content, challenge and domain tags) with the chosen identity’s key and POSTs it to the callback as {"event": …}.
  • The approval modal and its lifecycle (LoginState, one pending request at a time, LOGIN_EXPIRY_SECS = 120, password-gated signing, quiet outcome toast) live in goblin/src/gui/views/goblin/mod.rs.
  • The login keyword cannot collide with a pay recipient: a bech32 npub/nprofile always starts npub1/nprofile1.

References

Authorize Sessions (trust a site)

Summary. A site that speaks Goblin can ask you to trust it for a signing session. Once you do, it can sign low-risk actions for you (posts, reactions, direct messages, marketplace listings that carry no price, profile edits) without asking again, for this session only. Anything that spends or receives money always asks for your wallet password, every time. Your key never leaves the wallet, the wallet (never the site) decides which requests are money, and you can end any session from Settings → Trusted Sites at any moment.

Motivation

Signing in with Goblin proves who you are for one login and stops there: on its own it grants the site no power to publish as you. That is the right default, but it is tiring when a site legitimately needs to sign a stream of low-risk events on your behalf, for example a marketplace posting your chat messages, reactions, and listing edits as you browse. Approving every one of those with your password would be unusable; handing the site your key would be dangerous.

Authorize Sessions is the middle path. You grant one site, for one identity, the ability to sign a specific, low-risk set of actions silently for the length of a session. The wallet keeps a hard line around anything that moves value: those requests are never covered by the grant and always raise a per-action password prompt. The key stays in the wallet the whole time; only signatures leave.

How it works

The site asks you to trust it. As with login, the request arrives over a Goblin URI: on the same device a button opens the app, from another device you scan a QR. A trust request is a superset of a login request. On approval the wallet signs the one-time login event and opens an encrypted channel to the site so it can make signing requests during the session.

The site is told the session exists, reliably. Granting trust announces the new session so the site knows to start listening on the channel. The wallet now treats that announcement as confirmed only once it is accepted by the relay the site is actually watching (the one named in the request), and it re-publishes on its own until that happens rather than giving up after one try. On a cold Tor circuit that first reach can take a while, so the wallet waits longer before it will report a failure. The upshot is that a granted session actually lands with the site instead of the wallet quietly returning to the browser while the site never saw it.

The Trust screen. The wallet shows a single modal, Trust <domain>?, with:

  • Signing in as: the identity that will sign (your private tag or claimed name, with the npub beneath it). If you hold several identities you choose one.
  • What the site may sign silently: the low-risk categories it is asking for, shown in plain language, never as raw kind numbers:
    • Posts and reactions
    • Direct messages
    • Listings
    • Profile and lists
    • Deletes
    • Uploads and HTTP auth An event type the wallet does not recognize is shown on its own caution line rather than folded into a category.
  • The fixed money line: “Anything that spends or receives money will still ask for your password, every time. Buying, selling, and payment confirmations are never signed silently.” This is not something the site can turn off.
  • Login is never granted to a site. If the site asked for the login event type as part of the set, the wallet strips it and says so.
  • Duration: the grant lasts for this session only and can be ended any time from Settings → Trusted Sites.

You hold to trust for this session and enter your wallet password once. From then on, requests in the granted set are signed silently; everything else follows the rules below.

Two tiers, and the wallet decides. Every request the site makes is classified by the wallet from the event’s kind and its content, never from anything the site claims:

  • Low tier: signed silently, but only if the kind is in the set you granted. A low-tier kind you did not grant is refused, not signed.
  • Money tier: never silent. It always raises a Confirm prompt (“This moves or commits value, so it always asks”) and is signed only after you enter your password. Finalizing a purchase and posting a priced product listing are money tier by kind. In addition, a direct-message or order request whose readable content commits you to a payment is escalated to the money tier, so a pay-commitment hidden inside a message still asks. The classifier is fail-safe: when it is unsure whether something commits value, it treats it as money and prompts.

What is never signed at all. A login event and any delegation-bearing event are refused by the session outright, in every build, even through the money prompt. A session for one identity can never sign as another identity, and the wallet signs exactly the event the site composed (it never re-stamps the time or adopts a site-supplied id or signature).

Order messages (encrypt and decrypt). To build a sealed order message a marketplace needs the wallet to encrypt and decrypt with your identity key, not just sign. A trusted site can request those over the same channel. They are low risk like a silent sign, with two guards: an encrypted order message whose content commits to a payment escalates to the money prompt, and heavy message-reading surfaces an honest notice (below), because decrypting reads that identity’s messages.

Safety notices. If a trusted site signs unusually fast, the wallet shows a quiet, non-blocking notice, “A trusted site is signing a lot.” If it reads a lot of your messages, “A trusted site is reading your messages.” If the request rate crosses a hard ceiling, the session pauses: it stops signing silently and stays listed as paused until you resume or end it.

Trusted Sites

Settings → Trusted Sites lists every site you have trusted this session. Each entry shows the site, the categories it can sign silently, and how long the session has left. From here you:

  • End session to revoke a site immediately. The wallet tears the session down on its side at once and tells the site the session ended.
  • Resume a session that paused itself after a burst.

Sessions are held in memory only, so they are inherently temporary: closing or restarting the wallet ends every one of them. Even if you never touch it, a session ends after it goes idle for a while and cannot outlive a hard time cap. It is always safe to end a session you no longer recognize.

Screenshots: Trust modal showing domain, identity, granted categories, the fixed money line, and Hold to trust; the Confirm (money) prompt; Settings → Trusted Sites with one active session, time remaining, and End session, dark, 390×844.

Reference

  • The trust request URI (goblin:trust?c=<64-hex nonce>&d=<domain>&cb=<https callback>&sk=<site channel pubkey>&r=<wss relay hint>&k=<csv kind set>; the nostr:trust QR form is byte-identical) is parsed fail-closed in goblin/src/nostr/trusturi.rs. Any single validation failure rejects the whole URI before any modal can open.
  • The two-tier core lives in goblin/src/nostr/session.rs: classify() decides Low vs Money from kind and content; the money-tier kinds are purchase-finalize (17) and product listing (30402); the flagged conversation kinds (13, 14, 16, 1059) escalate to money when their readable content commits a payment; sanitize_kind_set() strips the login kind and every money kind from any requested set before it is stored; sign_session_event() pins the client’s created_at, binds to the session identity, and refuses the login kind and any delegation tag.
  • Requests and responses ride an encrypted, addressed channel (event kind 24140, NIP-44 v2 envelopes with a NIP-40 short expiration) bound to the site’s ephemeral channel key. A session is bound to a single identity, deduplicates replays, and enforces size caps, a rate limit, an idle timeout, and a hard time cap (memory-only, so a restart ends it).
  • The Trust grant modal, the money-tier Confirm prompt, and the Trusted Sites screen live in goblin/src/gui/views/goblin/mod.rs, with copy in goblin/locales/*.yml under goblin.trust, goblin.money, and goblin.trusted_sites.

References

Advanced Privacy & Anonymous mode

Summary. Settings → Advanced Privacy is the single home for how much the wallet reveals on this device. It has two parts: notification privacy, with three levels of how much a lock-screen alert prints, and Anonymous mode, which blurs your balance and history on the wallet’s own screens until you tap to reveal. Both are presentation only: they change nothing about how money moves or what is stored, so turning them on or off is always safe.

Motivation

Goblin already keeps the network from linking a payment to you: payment contents are end-to-end encrypted, and with Tor routing on the relay never sees your IP either. Advanced Privacy covers the other threat, the person standing next to you. A notification on your lock screen or a glance at your open wallet can leak who paid you and how much, even though the network learned nothing. This page gathers those shoulder-surfing defences in one place, separate from that network privacy.

It pairs naturally with keeping separate, unlinkable identities: different faces for different contexts on the network, and a censored surface so a shoulder glance cannot read any of them either.

Notification privacy

The Notifications section decides how much an incoming-payment or payment-request alert prints on your lock screen. Three toggles, from least to most private:

  • Hide amounts. The alert still names who paid, but the figure is left off, so a glance over your shoulder reveals nothing about the size of the payment.
  • Hide names. The alert leaves out who paid, showing a generic sender instead of a name or npub.
  • Hide all details. The alert collapses to a single generic private line, “You got paid. Open Goblin to see.” (and the request equivalent), with no name and no amount at all. On Android an empty amount collapses the notification template down to just that private line.

Hide all details takes precedence, and turning it on visually locks Hide amounts and Hide names on — the alert is already stripped to the single private line, so those two can add nothing while it is set. Your own Hide-amounts and Hide-names choices are preserved underneath the lock: turn Hide all details back off and each returns to whatever you last set it to. With Hide all details off, hide names and hide amounts apply independently, so you can hide just the figure, just the sender, or both. Hide amounts is the same setting that older builds exposed as the lone “Hide amounts” toggle, so upgrading keeps its exact meaning; the two new levels default off.

Anonymous mode

Anonymous mode is a single toggle, “Blur balance and activity”, that censors what the wallet shows on this device. It is scoped to three surfaces, the wallet Home balance, the activity list, and the Recent strip, and it changes only what is drawn, never the money path or storage.

While it is on:

  • The balance is a row of five dots. The Home balance hero renders a fixed count of dots instead of the number. The count is fixed and never derived from the real balance, so its width can never hint at the magnitude. Tap it to reveal the true figure. Leaving the Home tab re-censors it, so a later glance is blurred again.
  • The fiat rate is not fetched while censored. Because the fiat line is what triggers the exchange-rate lookup, no rate request goes out until you tap to reveal. A censored balance makes no network noise for its fiat value.
  • Activity rows are dotted. Each row’s name and amount become dots and the memo is dropped, so nothing about a counterparty or a figure leaks. Tapping a row reveals it and opens the full detail, which is the “reveal” for the activity list.
  • Every avatar becomes one uniform tile. In place of each counterparty’s picture, gradient, or initial, the wallet draws a single identical tile: a solid Goblin-yellow (#FED60E) circle with the Goblin mark inked dark on top. It is byte-identical for every identity, so no per-user colour, image, or letter can leak who a row belongs to.
  • The Recent strip is anonymized like the rest. Every avatar in the Recent strip becomes the same censored tile and every name is dotted. Tapping a tile still opens the full detail.

The amount dots are always the same fixed count and are never digit-matched to the real value, so a censored row cannot leak an amount’s size. The uniform yellow tile and the amount dots exist only while anonymous mode is on: turn it off and every avatar, including your own, returns to its normal colour gradient and every figure to its real value (Build 158 made this scoping explicit). Anonymous mode is purely a display choice: it does not touch the balance the wallet actually holds, the history it stores, or anything it sends.

Screenshots: Settings → Advanced Privacy (Notifications toggles + Anonymous mode); Home with the balance censored to dots; the activity list with dotted rows and the uniform yellow tiles, dark, 390×844.

Reference

In goblin/src/gui/views/goblin/:

  • mod.rs: SettingsPage::AdvancedPrivacy and advanced_privacy_ui() (the two sections). CENSOR_DOT_COUNT = 5, censored_amount_dots() (fixed dot string, ignores the real amount), CENSOR_NAME_DOTS (the fixed dotted name), and censored_balance_hero() (the tappable dotted balance that skips the fiat fetch until revealed). balance_revealed resets when Home is left.
  • widgets.rs: avatar_censored() draws the uniform #FED60E tile with the Goblin mark (reusing the img/goblin-logo2 asset).
  • Notification levels: AppConfig::hide_amounts() / notif_hide_names() / notif_hide_details() in config, read where the received / requested notifications are built in goblin/src/nostr/client.rs; the generic strings are goblin.settings.notif_private_received / notif_private_requested / notif_someone. The Android template collapses an empty amount in BackgroundService.java.
  • Config migration: the existing hide_amounts field keeps its meaning; the three new fields default off, covered by a config test. All new copy is in goblin/locales/*.yml under goblin.advprivacy and goblin.settings.

References

Onboarding

Summary. First run walks you from nothing to a funded, named wallet: create or restore a wallet, confirm your recovery phrase, choose your network privacy, and optionally claim a username, with a prominent skip so you can stay anonymous. Goblin connects to a Grin node automatically, so there’s no node setup to wade through.

Motivation

Goblin’s audience isn’t only Grin veterans. The first-run flow has to teach just enough (a recovery phrase is your money; a username is optional and public) without burying a newcomer in node configuration or nostr jargon. It reuses GRIM’s proven mnemonic machinery so the security-critical parts are the upstream-tested ones.

How it works

The flow (OnboardingContent) steps through:

  1. Intro: what Goblin is (private, pay-by-username). Goblin connects to a default Grin node automatically, so there’s no node step to wade through; you can change the node later in Settings → Advanced.
  2. Wallet setup: name + password, or choose restore.
  3. Recovery phrase: generate (12–24 words) or import. Import supports paste, a SeedQR scan, and (Build 158) a Choose a .backup file picker: point it at a full wallet .backup, unlock it with the wallet password, and it fills the 24-word grid for you. This step uses GRIM’s MnemonicSetup word grid and validation.
  4. Confirm words: verify the phrase by re-entering it.
  5. Network privacy: choose whether the wallet’s Nostr traffic rides Tor. The step explains what goes over the network (“Goblin sends only a few things over the network, each sealed with end-to-end encryption so relays can’t read them or link them to you.”) and offers the “Route through Tor” switch, captioned “Hide your IP from relays.” A brand-new wallet defaults this off (clearnet); it applies to this wallet and is changeable later under Settings → Privacy → Tor routing. Wallets that update from an older version keep Tor on and don’t see this step.
  6. Identity: optionally claim a username (reusing the name-authority claim flow) or import an existing identity (nsec / backup). A prominent Skip keeps you anonymous.

On completion the new wallet is opened and its NostrService starts. Restoring from a bare seed phrase gives you a fresh random nostr identity by default; you bring an old one back via Import. Restoring from a full .backup file instead brings every identity it held back automatically once the wallet opens (a “Restoring your identities…” card shows the progress), with the previously active identity re-selected.

Screenshots: Intro, Recovery-phrase grid, Claim-username step, dark, 390×844.

Reference

In goblin/src/gui/views/goblin/onboarding.rs:

  • OnboardingContent + Step enum. The live flow is Intro → WalletSetup → Words → ConfirmWords → Network privacy → Identity; the legacy Node step is retired (#[allow(dead_code)]) and the wallet auto-connects to a default public node, with node management in Settings → Advanced. The network-privacy step writes the wallet’s Tor-routing preference (default off for a fresh wallet); updated wallets keep their existing on setting and skip the step.
  • OnbImport: optional identity import (nsec / backup, with password when sealed), async worker result.
  • Reuses GRIM MnemonicSetup.word_list_ui (made pub(crate)), with SeedQR scan.
  • Hosted in goblin/src/gui/views/wallets/content.rs (replaces only the empty-state branch; the stock GRIM wallet-creation path stays for later wallets).

References

Cancel & decline

Summary. Two related “call it off” actions. Cancel payment appears on a payment you sent that hasn’t completed, and reclaims your funds. Decline appears on a request someone sent you, and tells them no. Both are deliberately gated so they’re hard to trigger by accident, which is exactly why they’re hard to catch in a screenshot.

Motivation

Interactive payments can get stuck: the recipient never comes online, or you change your mind before the second leg arrives. You need an escape hatch, but a careless one is dangerous, because cancelling a payment that has actually completed could look like free money or double-spends. So both actions are guarded:

  • Cancel payment only appears after a grace period (so you don’t cancel a payment that’s about to complete), and it refuses once the payment has truly gone through.
  • Decline is a normal, immediate choice on a request, but a request only exists transiently, when someone has sent you one.

How it works

Cancel payment (outgoing)

On the receipt screen for a payment you sent, a Cancel payment button appears when the payment is still pending (Created / AwaitingS2 / SendFailed), not yet confirmed, and either the send failed or the grace window (cancel_grace_secs, default 10 minutes) has elapsed. It’s a two-tap confirm (the label changes to a confirm state on first tap). Confirming dispatches WalletTask::NostrCancelSend(slate_id), which reclaims the locked outputs so your balance returns. The result is shown for a few seconds:

  • success → “Payment cancelled, your funds are available again” (positive green);
  • lost the race → “This payment already went through and can’t be cancelled” (dim).
Screenshot (rendered in isolation): receipt screen with the Cancel payment button: secondary outline button, 56 px tall, t.line border. Hard to reach live because it's grace-gated.

Decline (incoming request)

When someone sends you a payment request (Invoice-1), it shows as a card with the requester, amount and optional note, and two half-width buttons: Decline and Approve (approve is hold-to-accept). Decline marks the request Declined and dispatches WalletTask::NostrDeclineRequest, which sends a void control message back to the requester.

Screenshot (rendered in isolation): incoming-request card with Decline / Approve, needs a live incoming request to appear, so it's reproduced from the real widgets for the docs.

One wire message: “void”

Cancel-a-request and decline-a-request are the same message (“this request is off”), differing only by who sends it (the requester cancels; the payer declines). It’s a kind 14 rumor tagged ["goblin-action","void", <slate_id>], gift-wrapped like any payment. Cancelling an outgoing payment additionally reclaims your outputs locally. See the protocol.

Reference

  • Cancel-payment button + two-tap confirm + outcome copy: goblin/src/gui/views/goblin/mod.rs (receipt screen, cancel_confirm state; WalletTask::NostrCancelSend). Gating uses cancel_grace_secs from config.
  • Decline button on the request card: goblin/src/gui/views/goblin/mod.rs (request row; decline_button()); WalletTask::NostrDeclineRequest. Outgoing-request cancel is WalletTask::NostrCancelOutgoing.
  • The void control message: goblin/src/nostr/protocol.rs (GOBLIN_ACTION_TAG, ACTION_VOID, build_control_tags(), extract_control()).
  • Button styling: w::big_action(..., secondary = true) in widgets.rs (transparent fill, t.line border, t.text ink).

References

Balance, node health & fiat rate

Summary. Home shows one balance, and Goblin is careful to tell you the truth about it. When you switch Grin nodes the change applies live, and while a balance is not yet trustworthy the wallet says so in plain words rather than flashing a misleading zero. An optional fiat (or Bitcoin/sats) figure sits under the balance, fetched fresh when you look at it.

Motivation

A balance is the one number a payments app must never lie about. Two moments make that hard: the seconds after you switch nodes (the new node has not answered yet), and any time the connected node is unreachable or still catching up. The naive behaviour, showing 0 ツ until data arrives, reads as “your money is gone.” Goblin instead treats “I don’t know yet” as its own state and labels it. The fiat pairing has the opposite problem: a rate that silently goes stale is worse than no rate, so Goblin fetches it when you actually look and is honest when it can’t.

How it works

Switching nodes applies live

The Grin node is chosen in Settings → Advanced. Picking a different node applies immediately, with no restart: the wallet re-points at the new node and the balance re-derives from it. There is no stale figure left over from the old node. The Integrated Node settings (for running a Grin node inside the app instead of using a public one) live under Settings → Advanced too.

Minimum confirmations

How many confirmations a payment needs before the wallet counts it as settled is an editable setting, just below the node section in Settings → Advanced. The default is 10. A higher number means waiting longer but with more certainty that the chain won’t reorganise under the payment; only lower it if you understand that trade-off. Your choice is remembered per wallet and persists across app updates, alongside your Tor routing and relay choices.

Honest balance states

Rather than show a bare 0 ツ whenever the number isn’t ready, Home carries a short subline that names the actual situation:

  • Unreachable when the connected node can’t be reached, so no balance can be trusted right now.
  • Stale when the last known balance predates the current chain tip, i.e. the wallet has data but it may be out of date.
  • Updating while a fresh balance is being derived (typically right after a node switch or on reconnect).

A genuine zero balance is only ever shown when the wallet is confident the balance really is zero. The distinction is the whole point: “I can’t reach the node” and “you have nothing” are different facts and are shown as different facts.

The fiat rate is view-triggered, not polled

Under the balance you can show its value in a world currency, in Bitcoin, or in sats (or turn the preview off entirely). The rate is fetched when the view needs it, cached for a short freshness window, and reused within that window so a quick glance doesn’t refetch. It is not polled in the background: Goblin does not sit and fetch prices on a timer. If a fetch fails, the wallet shows rate unavailable rather than a guessed or indefinitely stale number. Like every other HTTP request, the rate fetch rides Tor when Tor routing is on, and goes direct otherwise.

References

  • Balance and rate rendering, and the appearance pairing picker: goblin/src/gui/views/goblin/mod.rs.
  • Node selection: Settings → Advanced (goblin/src/gui/views/goblin/mod.rs), applied to the running wallet without a restart.
  • Amount pairing options (currency / BTC / sats / off): Theme & appearance.

Theme: light / dark / yellow

Summary. Goblin has three themes (Light, Dark, and a high-contrast Yellow) driven by a single set of design tokens. The tokens distinguish “text on the background” from “text on a surface,” which is what makes the bright-yellow theme readable.

Motivation

A payments app is used in sunlight and in bed; some people want the brand’s yellow front-and-center. Centralizing every color into one token struct (rather than scattering hex values) means a new theme is just a new token set, and accessibility fixes happen in one place. The Yellow theme in particular forced a clean separation: on a bright background, on-surface text needs different colors than on-background text, so the token set carries both.

How it works

ThemeKind selects one of three ThemeTokens palettes. Tokens cover backgrounds (bg, surface, surface2), text on the background (text, text_dim, text_mute), text on surfaces (surface_text, surface_text_dim, surface_text_mute), plus line, accent (the Goblin yellow #FFD60A), positive/negative status colors, hover, and eight (background, ink) avatar pairs. The selected theme persists in app config and is chosen from the Settings appearance picker.

The rule for contributors: any new on-card text must use the surface_text* tokens, never the on-background text* tokens, otherwise it goes black-on-bright in the Yellow theme.

The docs site you’re reading reuses this palette (Geist type, #FFD60A accent on #0E0E0C ink) so it feels like the app.

The Appearance settings section

The theme picker lives in an Appearance section of Settings that gathers the look-and-feel choices in one place:

  • Theme. Light, Dark, or Yellow, as above.
  • Language. A selector for the wallet’s display language; Goblin auto-detects your system language on first run and this lets you override it. See Localization.

The lone “Hide amounts” notification toggle that once sat here now lives on its own page alongside two further notification-privacy levels and the Anonymous-mode balance blur: see Advanced Privacy & Anonymous mode.

When an app update is available, an update button appears near the profile panel. Tapping it opens the update dialog, which carries Goblin’s own branding and the changelog for the new version, along with links to the source on github.com/2ro/goblin, the community on t.me/goblinfamily, and these docs at docs.goblin.st.

Reference

  • goblin/src/gui/theme.rs: ThemeKind (Light / Dark / Yellow), ThemeTokens, the LIGHT / DARK / YELLOW palettes, the avatar pairs, and theme::tokens() / ink_for() helpers.
  • Picker: appearance section in goblin/src/gui/views/goblin/mod.rs settings.

References

Avatars & identicons

Summary. Every account gets a distinctive avatar with no upload and no server storage: a two-tone gradient deterministically derived from the public key, with the Grin mark or the person’s initial on top. The derivation is byte-identical across platforms.

Motivation

Faces make a contact list scannable. But hosting user images means storage, moderation, and a privacy leak (who fetched whose picture, from where). Goblin sidesteps all of it: an avatar is a pure function of the pubkey, computed on the device. Same key → same avatar, on every platform, forever, so you recognize a contact by their colors even before a name resolves.

How it works

The pubkey (normalized to lowercase hex) is hashed with SHA-256; bytes of that hash choose two hues, a blend offset, and a gradient angle (HSL→RGB, all in f64 so independent ports produce identical bytes). The result is rendered as an SVG gradient. On top:

  • a letter (the contact’s initial) for named users, or
  • the Grin mark for anonymous keys.

When a contact does publish a picture in their Nostr profile, Goblin can render that instead; otherwise the deterministic gradient is the fallback, so there is always an avatar. The eight theme avatar pairs supply complementary ink colors so initials stay legible.

Reference

  • goblin/src/gui/views/goblin/identicon.rs: to_hex_seed(), gradient_params() (SHA-256 → hues/angle), gradient_bg_svg(), gradient_avatar_svg() (gradient + Grin mark), GRIN_PATH, LOGO_FRAC, LOGO_OPACITY.
  • goblin/src/gui/views/goblin/widgets.rs: avatar(), gradient_avatar(), gradient_letter_avatar(), avatar_any() (dispatch to the best available avatar).
  • Picture handling/processing: goblin/src/nostr/avatar.rs (format sniff, square-crop, resize, metadata strip).

References

QR & camera

Summary. Goblin reads and writes QR codes for the things you hand to another person face-to-face: your payment code (nprofile), and your recovery phrase (SeedQR). The camera path decodes standard and animated (multi-frame) QR codes across platforms.

Motivation

In person, a QR is the fastest “address exchange” there is, and for a recovery phrase, scanning beats re-typing 24 words. Goblin uses QR in two directions: show your code so someone can scan-to-pay you, and scan a code to fill a recipient or import a seed. The scanner deliberately refuses to echo sensitive scans (seeds, raw slatepacks) into the UI.

How it works

  • Showing. The Receive screen and “My Code” tab render your nprofile (npub + relay hints) as a QR so a payer can scan it and reach you with no lookup, with the black Goblin logo centred in the code. The screen’s Share and Copy actions hand over the same nprofile string. Long payloads use animated Uniform Resources (UR): a sequence of frames.
  • Scanning. The camera feed is decoded with rqrr; the recipient row and home header offer a scanner for scan-to-pay, and onboarding offers a SeedQR scan to import a phrase. Only text QR results are accepted into the recipient field. A plain nostr:<nprofile> code fills only the recipient; a pay-URI code (for example a GoblinPay checkout QR, nostr:<nprofile>?amount=…&memo=…) also fills the amount and note, so scanning a checkout needs no retyping. Prefilling is all scanning does: the amount and review screens still confirm the send.
  • Cross-platform camera. Backed by nokhwa (V4L on Linux, MSMF on Windows, AVFoundation on macOS). Frames that arrive as raw YUYV are decoded before QR scanning; a “No camera found” state appears if nothing opens.
Screenshot: the in-app "Scan to pay" camera panel and the Receive "My Code" QR, dark, 390×844.

Reference

  • goblin/src/gui/views/camera.rs: CameraContent, the nokhwa capture + rqrr decode, UR reassembly, the unavailable-camera timeout.
  • goblin/src/gui/views/qr.rs: QrCodeContent generation (qrcodegen), animated UR output.
  • Scan entry points + accepted payloads: goblin/src/gui/views/goblin/send.rs (scan, ScanTab).

References

Localization

Summary. Every user-facing string in the Goblin surface goes through translation keys. Ten locales ship today (English, German, French, Russian, Turkish, Simplified Chinese, Traditional Chinese, Spanish, Korean, Japanese), and a test fails the build if any key is missing from any locale.

Motivation

Goblin is aimed at a global audience, so hard-coded English is a non-starter. The constraint that matters operationally is drift: as features are added, it’s easy for a new string to exist in en.yml but nowhere else. A parity test turns that from a silent gap into a failing test.

How it works

Strings are referenced with the t!("goblin.…") macro and defined in per-locale YAML under goblin/locales/. The ten files (en, de, fr, ru, tr, zh-CN, zh-TW, es, ko, ja) share an identical key tree. An integration test loads all of them and asserts every goblin.* key present in one locale is present in all, so adding a key means adding it everywhere. The display language is auto-detected from the system locale on first run and can be overridden in Settings.

For example, the recent UI change that renamed the relay row added a goblin.settings.nostr_relays key to all ten files at once; the parity test is what guarantees that. Beyond parity, the non-English locales get periodic full translation passes as screens change, so the wording stays natural rather than merely present.

Reference

  • goblin/locales/{en,de,fr,ru,tr,zh-CN,zh-TW,es,ko,ja}.yml: the string tree.
  • goblin/tests/i18n_keys.rs: every_locale_has_all_goblin_keys (the drift test).
  • Usage: t!("…") call sites throughout goblin/src/gui/views/goblin/.

References

Security hardening

Summary. A grab-bag of the defensive choices that don’t fit on one feature page: never auto-paying a request, binding replies to the expected counterparty, hard size ceilings, encrypted keys at rest, replay protection, rate limiting, and (when Tor routing is on) routing everything over Tor. This page is a map to where each lives.

Motivation

A wallet that accepts messages from strangers and moves money is an attractive target. Goblin’s posture is defense-in-depth: assume any incoming message is hostile, validate before acting, cap everything, and never let the network see more than ciphertext.

The measures

MeasureWhat it preventsWhere
Requests are never auto-paidA stranger draining you with an Invoice-1Ingest policy (decide()SurfaceRequest)
Replies bound to counterparty + pending txA forged Standard-2/Invoice-2 finalizing somethingIngest policy
Size ceilings (64 K / 32 K / 30 K / 256)Memory-blow-up / DoS via huge messagesProtocol constants
Encrypted key at restOffline key theft; password grindingIdentity: NIP-49 ncryptsec, scrypt log_N=16, 0600
Processed-id archive + 30-day TTLReplaying an old payment messageStorage (processed db)
NIP-98 single-use authReplaying a name registration requestName authority
Per-sender rate limitsSpam flooding from one keyNostrService (contact 30/h, unknown 10/h)
Everything over Tor, no clearnet lookups (when Tor routing is on)Your IP / network location exposed to the relay and on-path observersTor, Name resolution
Relay-side randomized release + NIP-59 backdatingMatching a send to a receive by timingTor pillar
Hostname-validated TLS over every circuitA hostile hop or lying resolver reading or MITMing a connectionTor, Tor exit path
NIP-44 v3 context binding (when negotiated)Ciphertext from one wrap layer replayed as the otherProtocol
Relays gated by a local NIP-11 probeA broken or hostile relay pool entry silently dropping paymentsRelays
Reserved names, homograph folding, cooldownImpersonation / squatting on namesName authority
Tag-independent classificationA sender lying about message type via tagsProtocol (classify by parsed slate only)

On the server side, the name authority runs under a hardened systemd sandbox and trusts an X-Real-IP set by its reverse proxy for rate limiting. Because many Goblin clients reach the relay over Tor (those connections arrive from shared Tor exit IPs), server-side abuse controls are tuned to be per-connection / per-account rather than naive per-IP.

References

  • Ingest invariants: goblin/src/nostr/ingest.rs.
  • Protocol ceilings + tag-independence: goblin/src/nostr/protocol.rs.
  • Key at rest: goblin/src/nostr/identity.rs.
  • Replay protection: goblin/src/nostr/store.rs and the server’s NIP-98 handling.
  • Live guards are covered by goblin/tests/{nostr_e2e,replay_check}.rs.

Self-hosting overview

Summary. Goblin’s public infrastructure (the goblin.st name authority and the default relay.floonet.dev relay) is all run-your-own. Wallets reach relays over a Tor exit (not an onion) whenever Tor routing is on, so there’s no separate onion service to run, just make sure your relay accepts Tor exit traffic. None of it is a hard dependency: you can point a Goblin wallet at your own name authority and your own relay, and build the app from source.

Why self-host

Defaults are conveniences, not gatekeepers. Running your own pieces gives you:

  • Independence: your community isn’t reliant on one operator for names or relaying.
  • A smaller metadata footprint: your users’ name lookups and messages stay on infrastructure you control.
  • Federation: your name authority issues name@yourdomain, and Goblin can be told to treat it as home.

The pieces

ServiceWhat it doesGuide
Name authority (goblin-nip05d)Issues names, resolves NIP-05, NIP-98 authRun a name authority
Relay (a Floonet package)Carries the encrypted payment messagesRun a relay
Tor reachabilityNothing to run; just make sure your relay doesn’t block Tor exit trafficTor and your relay
The app itselfBuild for desktop / AndroidBuilding Goblin

Pointing a wallet at your infra

  • Name authority: Settings → Identity → Name authority → set your domain. Bare names then resolve against it.
  • Relays: Settings → Nostr Relays → add your wss://… and save & reconnect. There’s nothing to configure for Tor: every wallet dials your relay over a Tor exit automatically, so the only requirement on your side is not blocking Tor exit-node traffic (see Tor and your relay).

These docs keep deployment generic. Adapt paths, domains, and certificates to your own host; don’t copy another operator’s production specifics.

Run a name authority

Summary. goblin-nip05d is a small, self-hostable Axum + SQLite service that issues name@yourdomain identities and resolves them via NIP-05, with NIP-98-authenticated self-service registration. Running your own makes you an independent issuer; goblin.st is just one operator.

Which package to use. Standalone goblin-nip05d is the original minimal edition, kept for anyone who wants names with no relay. The same name service is now bundled into the Floonet relay packages (floonet-strfry and floonet-rs), which can run it alongside the relay, standalone, or not at all, chosen in the relay setup, and which is where the authority now gets its ongoing work (paid names, name transfers, co-location on the relay domain). For a new deployment prefer the relay package; see the Floonet relay docs at docs.floonet.dev. The rest of this page covers the standalone service.

What it is

A single binary that:

  • answers GET /.well-known/nostr.json?name=<name> with the pubkey + advertised relays (NIP-05);
  • authorizes every write (register, release, transfer) with a signed Nostr event in the Authorization: Nostr … header (NIP-98): the key is the account, no passwords;
  • stores only names and pubkeys, no avatars, no PII (clients render avatars from the pubkey).

It pairs with a relay (which the bundled Docker Compose can run for you) but only advertises the relay; it isn’t one.

Security model (built in)

  • Cryptographic ownership, no recovery: lose the key, lose the name; the operator cannot reassign it.
  • Anti-squatting: a reserved list (admin, support, …), your own domain label reserved automatically, and look-alike/homograph folding; extend via GOBLIN_RESERVED_FILE.
  • One active name per key: enforced by a partial unique index at the DB layer.
  • Rate limiting keys off X-Real-IP: your reverse proxy must set it from the real client address, or the limiter is defeated. The provided proxy configs do this.

Deploying

The repo ships ready-to-adapt configs in goblin-nip05d/deploy/:

  • goblin-nip05d.service: a hardened systemd unit (DynamicUser, ProtectSystem=strict, StateDirectory, etc.). Set NIP05_DB to your state path.
  • nginx.conf.example / Caddyfile: TLS termination that proxies /.well-known/nostr.json and /api/ to the service (on its loopback port) with X-Real-IP set, and the relay websocket to strfry.
  • strfry/: the bundled relay write-policy (see Run a relay).
  • A Docker Compose option runs the service + relay + auto-HTTPS together.

Rough shape:

# build
cd goblin-nip05d && cargo build --release
# run (bare-metal): install the binary, set env, enable the unit
sudo install -m755 target/release/goblin-nip05d /usr/local/bin/
sudo systemctl enable --now goblin-nip05d
# front it with TLS + X-Real-IP per deploy/nginx.conf.example

First-run setup wizard. You don’t have to write the configuration by hand. Start goblin-nip05d interactively with no configuration and it launches a short guided setup: it asks for the essentials, suggests a sensible default for each answer, and writes the config for you. With a config file or environment variables already present it starts headless exactly as before, so scripted, Docker, and systemd deployments are unaffected.

Then point a wallet at it: Settings → Identity → Name authority → yourdomain.

Optional: name sales. The name marketplace is off by default and per-authority: enable it with GOBLIN_ALLOW_TRANSFERS=true plus GOBLIN_GRIN_NODE_URL pointing at a Grin node’s foreign API (read-only chain access, used only to confirm payment kernels; the authority never runs a wallet and never holds funds).

Reference

  • Crate: goblin-nip05d/ (Axum + SQLite). README documents endpoints, env, and the security model in full.
  • Endpoints: GET /.well-known/nostr.json, GET /api/v1/name/{name} (availability), POST /api/v1/register, DELETE release, transfer, and by-pubkey reverse lookup.
  • Deploy templates: goblin-nip05d/deploy/{goblin-nip05d.service,nginx.conf.example,Caddyfile,strfry}.
  • Client side: The NIP-05 name authority.

References

Run a relay

Summary. Goblin’s payment messages ride ordinary Nostr relays, so any relay works, as long as it accepts connections from Tor exit nodes (Goblin dials relays over Tor whenever a wallet’s Tor routing is on, and many wallets keep it on). The default relay (relay.floonet.dev) is a Floonet relay: stock strfry with a small write policy that restricts stored event kinds to the handful Goblin needs. Running your own keeps your community’s traffic on infrastructure you control.

Motivation

A relay only needs to do one thing for Goblin: accept and serve the gift-wrapped payment events (and the profile / relay-list events that make delivery work). Restricting which kinds it stores keeps a payment relay lean and uninteresting to abuse; it isn’t a general-purpose social relay.

How it works

  • strfry, unmodified, plus a write-policy plugin that only admits the kinds Goblin uses: profiles (kind 0), contact/relay lists, gift wraps (kind 1059), and the relay-list kinds (10002 / 10050). Everything else is rejected, so the relay won’t fill with unrelated content.
  • Clients reach it over wss://, over Tor: every wallet connection arrives from a shared Tor exit IP, which is why abuse controls are per-connection rather than naive per-IP.
  • It’s typically fronted by the same TLS reverse proxy as the name authority, with the websocket location proxied to strfry’s loopback port.
  • Payment retention. The point of a payment relay is to hold a gift-wrap until its recipient comes online, so don’t set an aggressive expiry or eviction policy that could drop an unfetched payment. The project’s relay.floonet.dev guarantees payment-message retention (it cannot prematurely delete a payment), which is why it ships as the default relay in every wallet; run yours the same way.
  • Optional NIP-42 auth. Goblin wallets do opportunistic NIP-42 auth automatically, so a relay may enable it and wallets will answer. Do not make it mandatory or paid, though: the wallet’s pool gate refuses any relay that requires payment or AUTH.
  • Make sure Tor exit traffic isn’t blocked. Any wallet with Tor routing on reaches your relay over a Tor exit, so a CDN or WAF rule that blocks Tor exit nodes (Cloudflare’s “Block Tor” toggle, for example) will silently cut those wallets off. An earlier build ran a dedicated Tor onion service in front of the relay; that requirement is retired (see Tor and your relay), and a plain clearnet-reachable relay works fine today.

Deploying

The recommended path is a Floonet relay package: floonet-strfry (Docker Compose: relay + name authority + auto-HTTPS) or floonet-rs (a single hardened binary with the same features built in). A minimal strfry write-policy setup also lives under goblin-nip05d/deploy/strfry/ if you’d rather assemble it by hand. Then advertise the relay in your wallet: Settings → Nostr Relays → add wss://relay.yourdomain → Save & reconnect.

Reference

References

Tor and your relay (the onion service is retired)

Summary. Goblin wallets reach relays over Tor automatically: the client dials a Tor-exit circuit to your relay’s ordinary clearnet host. There is nothing for a relay operator to run for Tor support today. This used to not be true: builds through 133 needed operators to front their relay with a system-Tor onion service, which this page originally documented. Build 134 dropped the pinned onion (it flapped under load) in favor of the plain Tor-exit path, so that setup is retired.

What this means for operators today

Nothing to install. Wallets reach your relay the same way they’d reach it over any other Tor-exit connection: your relay’s normal wss:// endpoint. The one thing to check is that your relay (and anything in front of it, a CDN or WAF) doesn’t block Tor exit-node traffic; relay.damus.io and nos.lol do, which is why Goblin’s default pool no longer includes them. See Run a relay for the rest of what running a relay involves.

Historical: the retired onion service

For reference, in case you’re maintaining an older Floonet package or comparing against an old deployment, this is what an earlier build (133) asked operators to run:

# /etc/tor/torrc  (system Tor on the relay box)
HiddenServiceDir /var/lib/tor/floonet-relay/
HiddenServicePort 443 127.0.0.1:443

A HiddenServiceDir held the onion service’s keys, and wallets pinned the resulting .onion from the relay pool’s onion field. Build 134 removed that field from the pool schema, and the wallet no longer looks for it. An onion service left running alongside a relay today is simply unused, not harmful.

References

Building Goblin

Summary. Goblin is pure Rust on egui, building for Linux, macOS, Windows, and Android from one workspace. Desktop is a normal cargo build; Android uses the NDK via a helper script. Versioning is a build number derived from commits since the GRIM fork point.

Prerequisites

  • A recent Rust toolchain (edition 2024). goblin/scripts/toolchain.sh sets up what’s needed.
  • For Android: the Android SDK + NDK and cargo-ndk (driven by goblin/scripts/android.sh).
  • For the name authority / relay: build those from the goblin-nip05d crate separately.

Desktop

cd goblin
cargo build              # debug
cargo build --release    # stripped release binary (see [profile.release])
./scripts/desktop.sh     # convenience wrapper
cargo test               # unit + drift tests (live e2e tests are #[ignore])

The release binary is goblin. Linux packaging into an AppImage uses the linux/Goblin.AppDir/ layout; Windows embeds an icon via winresource; macOS builds a universal binary.

Android

cd goblin
./scripts/android.sh release '' <flavor>   # flavor is required; empty version auto-derives

This produces signed-or-debug APKs (arm v7/v8, plus x86_64 for emulators). The manifest is configured to survive configuration changes (orientation, dark-mode, locale) without restarting.

Versioning

There is no semver. build.rs computes Build N = commits since GOBLIN_FORK_BASE (b51a46b), or honors an explicit GOBLIN_BUILD env var (used for single-commit public builds). The build number shows in the title bar and About screen.

The public download page at goblin.st links to stable, unversioned paths (/dl/android, /dl/linux, /dl/appimage, /dl/windows, /dl/macos) rather than to a specific build. Each /dl/* path resolves to the matching asset on the latest GitHub release, so the site never has to be edited when a new build ships. The in-app updater is stricter: it matches assets by exact name (for example -android-arm.apk), so a release whose asset names drift will 404 the update.

Two rules keep the aliases working:

  1. Every release uploads the stable-named assets for all platforms alongside the per-build files, so the /dl/* aliases have something current to point at.
  2. Run the post-publish link check after the release is published: /opt/goblin/bin/check-dl-links.sh on the us-east host follows each /dl/* alias and verifies it resolves to a real asset on the latest release. Treat a failure as a broken download page.

Localization

To add or fix a locale, edit the YAML under goblin/locales/. The i18n_keys test enforces that every locale has every key; run cargo test --test i18n_keys. See Localization.

Reference

  • Build scripts: goblin/scripts/{desktop,android,toolchain,version}.sh, gen_icons.sh, make-icns.py.
  • Versioning: goblin/build.rs; profiles: goblin/Cargo.toml ([profile.release], [profile.release-apk]).
  • Tests: goblin/tests/{nostr_e2e,replay_check,i18n_keys}.rs.

References