Goblin
Goblin is a private, mobile-first payments app for Grin. Think Cash App, but the money is Grin, the addresses are usernames, and the whole conversation rides an anonymizing mixnet.
You send to @alice, not to a 90-character address. You tap Pay, hold to confirm, and a Grin payment travels end-to-end encrypted over the Nostr network and the Nym mixnet, so no relay, no network observer, and no chain analyst can tie the sender to the receiver.

Under the hood Goblin stands on three pillars:
| Pillar | What it gives Goblin |
|---|---|
| GRIM | A complete, audited Grin wallet + node engine: seed, sync, and the Mimblewimble slatepack transaction machinery. Goblin forks it and keeps it. |
| Nostr | The messaging layer. Usernames, encrypted payment messages (gift-wrapped slatepacks), and offline delivery, all without running our own bespoke server. |
| Nym | The transport. Every byte Goblin sends, relay traffic and every HTTP request, goes through a 5-hop mixnet. Nothing touches the clear net. |
How to read these docs
The docs are organized from the outside in:
- Overview: what Goblin is and how the pieces fit.
- Pillars: the three foundations (GRIM, Nostr, Nym), each broken into its component parts.
- Features: the things you actually do: pay, request, claim a name, onboard.
- Subsystems: the smaller machinery: themes, avatars, QR, localization, security.
- Operating Goblin: run your own name authority, relay, and mixnet exit; 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.
What is Goblin?
Summary. Goblin is a mobile-first wallet for Grin that lets you pay a username instead of swapping transaction files. It is a fork of the GRIM Grin wallet, with a Nostr-based messaging layer and a Nym mixnet 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 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.
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. Usernames are NIP-05 identifiers like
alice@goblin.st. -
Nym for anonymity. Every connection Goblin makes (relay sockets and every HTTP request for name lookups, price, avatars) is tunneled through the Nym mixnet, a 5-hop anonymizing network. This hides your IP from relays and breaks the timing correlation that an interactive payment would otherwise leak at the network layer. Nothing Goblin sends touches the clear net, except the Grin node connection, which carries only public chain data and is deliberately 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
- Architecture: the three pillars: how the pieces connect.
- The end-to-end payment flow: follow one payment from tap to chain.
- Nostr in Goblin and Nym in Goblin: the layers that make it private.
References
- Project README and banner:
goblin/README.md. - Crate metadata (package
grim, binarygoblin, fork base):goblin/Cargo.toml,goblin/build.rs. - Grin / Mimblewimble: https://grin.mw, https://github.com/mimblewimble/grin.
Architecture: the three pillars
Summary. Goblin is layered. A GRIM wallet/node core handles money; a Nostr layer handles messaging and identity; a Nym 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 · Activity · Receive · Me · Send flow · … │
├─────────────────────────────────────────────────────────┤
│ Nostr messaging (src/nostr/) │
│ identity · gift-wrapped slatepacks · ingest policy · │
│ relays · NIP-05 names · per-wallet service thread │
├─────────────────────────────────────────────────────────┤
│ Nym mixnet transport (src/nym/) │
│ in-process SOCKS5 client → 5-hop mixnet → exit │
│ ALL relay sockets + ALL HTTP go through here │
├─────────────────────────────────────────────────────────┤
│ GRIM wallet + node engine (wallet/, node/) │
│ seed · keys · sync · Mimblewimble slatepack tx machine │
└─────────────────────────────────────────────────────────┘
│ │
Nym mixnet 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. Gap it leaves: the relay still sees your IP, and an observer can correlate the two legs of the exchange by timing.
- Nym closes that gap: routing everything through a mixnet hides your network identity and breaks timing correlation. Result: who-pays-whom is private from the chain up and the network down.
What rides which transport
A deliberate split (see the project decisions):
| Traffic | Path | Why |
|---|---|---|
| Nostr relay sockets (payments, identity events) | Nym mixnet | Reveals who you talk to; must be hidden. |
| NIP-05 name lookups, price feed, avatars (HTTP) | Nym mixnet | Reveal who you are / who you’re paying. |
| Grin node connection (block sync, broadcast) | Direct | Public chain data, identical for everyone, not tied to your identity. Anonymizing it buys little and costs reliability. |
Code map
| Layer | Directory | Start here |
|---|---|---|
| UI | goblin/src/gui/views/goblin/ | mod.rs (GoblinWalletView) |
| Nostr | goblin/src/nostr/ | mod.rs, client.rs |
| Nym | goblin/src/nym/ | sidecar.rs, transport.rs |
| Wallet↔Nostr glue | goblin/src/wallet/wallet.rs | WalletTask::Nostr* |
| GRIM core | goblin/wallet/, goblin/node/ | inherited from GRIM |
| Identity server | goblin-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 through the Nym mixnet → 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,nym}/,goblin/wallet/,goblin/node/. - Transport split rationale:
goblin/README.md; see also Nym in Goblin. - Architecture overview diagram:
Goblin-Transport-Overview.pdf(project root).
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 → Node 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 three new trees that GRIM doesn’t have (src/nostr/, src/nym/, and src/gui/views/goblin/), plus 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, removes the old clearnet/Tor transport in favor of Nym, 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 isgoblin; seegoblin/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.rsdefinesGOBLIN_FORK_BASE = "b51a46b"(the GRIM commit Goblin forked from) and computes Build N = number of commits since the fork viagit rev-list. An explicitGOBLIN_BUILDenv 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 nym+nostr+grin tree is ~16 MB of symbols);[profile.release-apk]addsopt-level="z",lto,panic="abort"for Android size. - Upstream. GRIM lives at https://code.gri.mw/GUI/grim (author Ardocrat). Goblin’s
repositoryfield still points there.
To see exactly what the fork changed, diff the two trees:
diff -ru ../grim/src ./src # new: nostr/, nym/, gui/views/goblin/
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 thegoblintree). - 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.stto 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 a mixnet underneath for the metadata privacy Nostr alone doesn’t provide.
The parts
The Nostr layer (goblin/src/nostr/) breaks into six components, each with its own page:
| Page | What it covers | Key file |
|---|---|---|
| Identity | The nostr keypair, encryption at rest, rotation | identity.rs |
| Payment protocol | How a slatepack becomes a gift-wrapped event | protocol.rs |
| The NostrService | The per-wallet relay thread + send pipeline | client.rs |
| Ingest policy | What the wallet accepts, and what it never does | ingest.rs |
| Storage, config & types | The metadata archive and per-wallet settings | store.rs, config.rs, types.rs |
| Relays | Defaults, DM relay lists, the editor | relays.rs |
The NIPs Goblin implements
NIP-05 (names), NIP-06 (key derivation), NIP-17 (private DMs), NIP-19 (npub/nprofile encoding), NIP-44 (encryption), 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
goblin/src/nostr/: the whole layer (mod.rsre-exports the public surface).- Nostr protocol & NIPs: https://github.com/nostr-protocol/nips, https://nostrbook.dev.
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.
Motivation
Two design tensions shape Goblin’s identity:
- 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.
- 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
nsecor 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.
Rotation generates (or imports) a new key, releases your old name from the authority, records the old npub in prev_npubs, and restarts the relay service under the new key, all without re-seeding, so your Grin balance is untouched.
Backup & restore. “Back up to a file” exports the whole identity (encrypted key + name + history) as one sealed JSON file. Importing it on a new device decrypts with the export-time password and re-encrypts under the new wallet’s password, so moving devices preserves your username and history. (Moving a wallet needs both backups: the seed phrase for funds, and the identity file for your name + key.)
Reference
IdentitySourceenum and theNostrIdentitystruct:goblin/src/nostr/identity.rs. Fields:ncryptsec,npub,nip05,anonymous,prev_npubs.- Encryption at rest: NIP-49
ncryptsec, scryptNCRYPTSEC_LOG_N = 16. - File safety:
write_private()(Unix0600),restrict_dir()(0700); stored atwallet_data/nostr/identity.json. - Key derivation for the legacy
Derivedsource:derive_keys()(NIP-06 BIP-44 path). - Rotation/import/backup UI:
RotateState/ImportState/BackupStateflows ingoblin/src/gui/views/goblin/mod.rs; onboarding import inonboarding.rs(OnbImport).
References
- NIP-06 (key derivation from mnemonic): https://nips.nostr.com/6.
- NIP-19 (
npub/nsec/nprofilebech32): https://nips.nostr.com/19. - NIP-49 (encrypted secret key): https://nips.nostr.com/49.
- Identity is deliberately not the Grin seed; see GRIM base and project rationale in the wallet README.
The payment protocol (NIP-17 / 44 / 59)
Summary. A Grin slatepack is delivered as a NIP-17 private direct message: a
kind 14rumor carrying the slatepack armor, sealed and gift-wrapped (NIP-59) inside akind 1059event encrypted with NIP-44. 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:
- Confidentiality: a relay must not read the slatepack (it would reveal a pending payment and its amount).
- Sender privacy: a relay must not even learn who sent the message.
- 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 rawBEGINSLATEPACK…ENDSLATEPACKarmor. 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.
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:
| Limit | Value |
|---|---|
| Gift wrap content (before unwrap) | 64 KiB |
| Rumor content (after unwrap) | 32 KiB |
| Slatepack armor | 30 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(). - The actual sealing/wrapping and NIP-44 encryption are handled by the
nostr-sdkgift-wrap APIs, driven from the send pipeline.
References
- NIP-17 (private DMs): https://nips.nostr.com/17.
- NIP-44 (versioned encryption): https://nips.nostr.com/44.
- NIP-59 (gift wrap / seal): https://nips.nostr.com/59.
kind 1059gift wrap: https://nostrbook.dev/kinds/1059.- Grin slatepacks: https://docs.grin.mw.
The NostrService relay thread
Summary.
NostrServiceis the long-running, per-wallet engine that connects to relays (over Nym), 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-sdkclient whose relay transport is the Nym websocket transport, so every relay socket runs over the mixnet. - Subscribes for
kind 1059gift 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, plusREQUEST_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
@namewas reassigned or released is caught. - Serializes cancel vs. finalize with a lock, so a user-initiated cancel can’t race a concurrent auto-finalize of the same slate.
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:
NostrServicestruct:keys,client(relay pool),rt_handle,connected, per-senderratemap,send_phase(atomic) +last_send_error,cancel_finalize_lock.send_phaseconstants: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_syncingoblin/src/wallet/wallet.rs; jobs arrive asWalletTask::Nostr*. - Relay transport:
NymWebSocketTransport(goblin/src/nym/transport.rs).
References
- The send pipeline end to end: The payment flow.
- What the service accepts: Ingest policy.
- NIP-65 relay lists (
kind 10002) and NIP-17 DM relays (kind 10050): Relays.
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:
| Decision | When | Effect |
|---|---|---|
AutoReceive | A new payment (Standard-1) your policy lets in | Build the reply leg automatically |
SurfaceIncoming | A new payment under Contacts/Ask policy | Show it for you to accept |
FinalizePost | A reply (Standard-2 / Invoice-2) that matches a pending tx and the right counterparty | Finalize and broadcast |
SurfaceRequest | A request for you to pay (Invoice-1) | Show it for explicit approval; never auto-paid |
Drop(reason) | Anything else | Discard, 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 toAwaitingS2), because a send can crash between building and recording, but still only from the expected counterparty.
Reference
In goblin/src/nostr/ingest.rs:
IngestDecisionenum:AutoReceive,SurfaceIncoming,FinalizePost,SurfaceRequest,Drop(&'static str).IngestContextstruct: 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_e2eround-trip tests ingoblin/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 (counterpartynpub, 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, ablockedflag, and anunknownflag 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 onprocessed. (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 andgoblin/src/gui/views/goblin/data.rs.
Relays
Summary. Relays are the public servers that carry Goblin’s encrypted messages. Goblin ships sensible defaults, advertises a short DM-relay list (NIP-17
kind 10050) so others know where to reach you, and lets you edit the list. All relay traffic runs over the Nym mixnet.
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.
How it works
- Defaults. Out of the box Goblin uses a small set: a relay it operates (
relay.goblin.st) plus large public relays (relay.damus.io,nos.lol). The Goblin relay is a stock strfry with a write policy restricting stored kinds to the handful Goblin needs (profiles, relay lists, gift wraps). - Advertising reachability. Your wallet publishes a
kind 10050DM-relay list (capped at 3) so a sender’s wallet knows which relays to deliver your payment to.nprofileshares carry relay hints too, so a fresh recipient is reachable without any lookup. - Editing. Settings → Nostr Relays shows your list and lets you add
wss://…relays (URLs are normalized: a bare host getswss://). “Save & reconnect” rewrites yourkind 10050and restarts the service on the new set.
Note on the UI: the relay editor now 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.
Reference
goblin/src/nostr/relays.rs:DEFAULT_RELAYS,MAX_DM_RELAYS = 3,normalize_relay_url(); default name authority constants (HOME_NIP05_DOMAIN,DEFAULT_NIP05_SERVER).- 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
NymWebSocketTransport.
References
- NIP-17 DM relays (
kind 10050): https://nips.nostr.com/17. - NIP-65 relay lists (
kind 10002): https://nips.nostr.com/65. - Running your own: Run a relay.
Nym in Goblin
Summary. Goblin routes all of its network traffic (every Nostr relay socket and every HTTP request) through the Nym mixnet, a 5-hop anonymizing network. The Nym SDK is linked in-process and exposes a local SOCKS5 proxy at
127.0.0.1:1080; relays and HTTP both dial that. Nothing Goblin sends touches the clear net.
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:
- A relay you connect to learns your IP.
- Even with gift-wrapped messages, an observer who can see both legs of the exchange can correlate them by timing: the two-message ping-pong of a payment is a recognizable pattern.
A mixnet is the right tool against both. Nym batches and delays packets across five hops, so per-message timing is decoupled and the network can’t tell who is talking to whom. Goblin’s owner specifically wanted metadata privacy for the slatepack exchange, and found the previous transport (Tor) painfully slow to bootstrap. Nym connects in ~2 seconds and round-trips a slatepack-sized message in well under a second per leg.
How it works
The Nym SDK is a direct dependency, linked into the binary. There is no sidecar process and no bundled binary to ship or sideload. At startup Goblin warms up an in-process SOCKS5 mixnet client that listens on 127.0.0.1:1080. From then on:
- The Nostr relay transport dials relays through that SOCKS5 endpoint.
- Every HTTP request (name lookups, price, avatars) uses it as a
socks5hproxy (so DNS happens inside the proxy: no clearnet DNS leak either).
Both reach the mixnet exit (a network requester) and from there the public internet.
The three component pages:
| Page | Covers | File |
|---|---|---|
| The in-process mixnet client | Starting the SDK, the SOCKS5 endpoint, warm-up | sidecar.rs |
| Relay traffic over the mixnet | The websocket transport for the relay pool | transport.rs |
| HTTP over the mixnet | Routing reqwest through the proxy | mod.rs |
What goes over the mixnet, and what doesn’t
| Traffic | Path |
|---|---|
| Nostr relay sockets (payments + identity events) | Nym |
| NIP-05 lookups, price feed, avatar fetches | Nym |
| Grin node connection (sync, broadcast) | Direct: public chain data, not tied to your identity; anonymizing it adds latency for no metadata gain. |
References
- Layer entry points:
goblin/src/nym/{sidecar,transport,mod}.rs. - Why Tor was replaced by Nym, and the measured connect/round-trip numbers: project history; see The in-process client.
- Nym developer docs: https://nym.com/docs/developers/rust.
The in-process mixnet client
Summary. Goblin links the Nym SDK directly and runs its SOCKS5 mixnet client on a private tokio runtime, exposing the mixnet at
127.0.0.1:1080. It’s warmed up at app launch so the network is ready by the time you open a wallet. There is no subprocess and no bundled binary.
Motivation
An earlier design ran Nym as a separate nym-socks5-client sidecar binary, bundled per platform and launched as a subprocess. That worked but meant shipping and managing native binaries (especially awkward on Android). Once a dependency conflict that had blocked linking the SDK was resolved, Goblin moved the client in-process: simpler to ship (one binary), simpler to reason about, and a cleaner lifecycle. The SOCKS5 model was kept (rather than a bespoke peer-to-peer bridge) so Goblin interoperates with any relay and any NIP-05 host through a standard mixnet exit.
How it works
At startup warm_up() spawns a background thread:
- If something is already listening on
127.0.0.1:1080(e.g. an externally-run client), it’s reused as-is. - Otherwise the in-process client is built on a dedicated multi-threaded tokio runtime and started. It connects to the mixnet via SOCKS5, pointed at a network requester (the mixnet exit). Typical readiness is ~2 seconds.
A cheap, cached is_ready() flag (an atomic, safe to poll every UI frame) tells the rest of the app when the proxy is up, distinct from a relay actually being connected. The client (and its runtime) is held open for the whole process lifetime.
Persistence. The client’s identity and chosen gateway are stored under ~/.goblin/nym, so the gateway is picked once and reused across launches, which cuts cold-start time. If there’s no home directory, it falls back to ephemeral in-memory keys.
The network requester is a baked-in default address, overridable at runtime with the GOBLIN_NYM_PROVIDER environment variable. Self-hosters can run their own requester (see Run a Nym network requester) for reliability.
Implementation footnote (TLS provider). Linking Nym pulls in
aws-lc-rsalongside Goblin’sring. With rustls 0.23 unable to auto-pick a default crypto provider, the first TLS handshake would panic. Goblin installs the ring provider explicitly at startup (rustls::crypto::ring::default_provider().install_default()), withrustlsbuilt with theringfeature. Worth knowing if you hack on the transport.
Reference
In goblin/src/nym/sidecar.rs:
warm_up(): background start / reuse;is_ready()+MIXNET_READYatomic;port_open()TCP probe of:1080.run_client(): builds the tokio runtime, starts the SOCKS5 client, holds it open withstd::future::pending().build_client(): persistent storage (StoragePathsunder~/.goblin/nym) vs. ephemeral;Socks5MixnetClientviaMixnetClientBuilder.NETWORK_REQUESTERconstant +GOBLIN_NYM_PROVIDERoverride (provider()).- Warm-up is kicked off from app start (desktop
main.rsand Android entry) so the mixnet is ready before wallet open.
References
- The constants (
SOCKS5_HOST,SOCKS5_PORT = 1080) and proxy helpers:goblin/src/nym/mod.rs. - Consumers: Relay transport, HTTP.
- Nym SDK (Rust): https://nym.com/docs/developers/rust.
Relay traffic over the mixnet
Summary. Goblin gives the Nostr relay pool a custom websocket transport that dials every relay through the local Nym SOCKS5 proxy. The proxy resolves the relay host inside the mixnet (
socks5h-style, no clearnet DNS), then the TLS + websocket handshake runs over that tunnel.
Motivation
The nostr-sdk relay pool normally opens websockets directly. To put relay traffic on the mixnet 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.
How it works
For each relay URL the pool wants to connect to, NymWebSocketTransport::connect:
- Parses the host and port (defaulting 80 for
ws://, 443 forwss://). - Opens a SOCKS5 connection to
127.0.0.1:1080and asks the proxy to reach(host, port). Because the proxy does the DNS resolution inside the mixnet, the destination host is never resolved on the clear. - Runs the TLS (for
wss) and websocket handshake over that mixnet stream. - Splits the socket into a sink (writes) and a stream (reads), adapting tungstenite messages to the pool’s message type.
All of this is wrapped in the pool’s connect timeout. The result is an ordinary websocket from the SDK’s point of view: it just happens to traverse five mixnet hops.
Reference
In goblin/src/nym/transport.rs:
NymWebSocketTransportimplementsnostr_relay_pool::transport::websocket::WebSocketTransport;support_ping()istrue.connect(): host/port parse,tokio_socks::tcp::Socks5Stream::connect(socks5_addr, (host, port)), thentokio_tungstenite::client_async_tls(url, stream), split intoWebSocketSink/WebSocketStream.tg_to_message(): maps tungsteniteText/Binary/Ping/Pong/Closeto the pool’sMessage.NymSink: sink adapter converting pool messages back to tungstenite messages.- The SOCKS5 address comes from
crate::nym::socks5_addr()(127.0.0.1:1080).
References
- The proxy that serves
:1080: The in-process mixnet client. - The pool that uses this transport: The NostrService.
socks5h(DNS-in-proxy) rationale: HTTP over the mixnet.
HTTP over the mixnet
Summary. Every HTTP request Goblin makes (NIP-05 name resolution and registration, the price feed, avatar fetches) goes through the local Nym SOCKS5 proxy as a
socks5h://127.0.0.1:1080proxy. Generous timeouts account for mixnet latency. There is no clearnet HTTP path.
Motivation
It would be easy to leave “just a name lookup” or “just the price” on the clear net. 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. The rule is simple and absolute: everything over the mixnet, so there’s no accidental leak to audit for.
How it works
http_request_bytes() builds a reqwest client configured with the Nym proxy and a fixed goblin-wallet user agent, sends the request, and returns (status, body). Because the proxy URL uses the socks5h scheme, DNS resolution happens inside the proxy (mixnet), not locally. The timeout is generous (60 s) because the mixnet adds deliberate per-hop delay: a name lookup that would be instant on the clear net might take a couple of seconds here, which is fine for the interactions involved. A string-bodied convenience wrapper, http_request(), sits on top.
Callers include:
- NIP-05 resolution (
/.well-known/nostr.json?name=…), registration/release (NIP-98-authenticatedPOST/DELETE), and reversename-by-pubkeylookup. - Avatars: fetching a contact’s image from the authority.
- Price: the fiat/BTC rate for the amount preview.
Reference
In goblin/src/nym/mod.rs:
SOCKS5_HOST/SOCKS5_PORT = 1080;proxy_url()→socks5h://127.0.0.1:1080;socks5_addr()→127.0.0.1:1080(raw TCP for the relay transport).http_request_bytes(method, url, body, headers) -> Option<(u16, Vec<u8>)>: reqwest client withProxy::all(proxy_url()),user_agent("goblin-wallet"), 60 s timeout.http_request(...):String-bodied wrapper.
References
- Where these calls originate: NIP-05 name authority (
goblin/src/nostr/nip05.rs), and the price/avatar fetchers. - The proxy itself: The in-process mixnet client.
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 Nym. 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.
- Nym carries every byte.
- 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 Nym)
│ 2. GRIM builds Standard-1 slatepack
│ 3. gift-wrap (kind 1059) + publish ───┐
│ to Bob's kind 10050 relays │ Nym mixnet
│ over Nym └──────────────▶ 4. ingest: AutoReceive
│ 5. GRIM builds Standard-2
│ 7. ingest: FinalizePost ◀──────────── gift-wrap ──────┘ 6. publish reply (over Nym)
│ 8. GRIM finalizes the tx
│ 9. broadcast to Grin node (direct)
▼ 10. both wallets see it confirm (10 blocks)
- Resolve. If you typed
@bob, the wallet resolves it to annpubvia NIP-05, an HTTPS lookup that goes over the mixnet. (Paste annpub/nprofileand this is skipped; relay hints may come along for free.) - Build leg 1. GRIM creates the Standard-1 slatepack for
5 ツto Bob and recordstx_meta(direction = Sent,status = Created). - Wrap & send. The send pipeline builds a
kind 14rumor (preamble + slatepack + note), seals and gift-wraps it (NIP-59), and publishes thekind 1059to your relays and Bob’s DM relays, all over Nym. Status →AwaitingS2. The UI shows a spinner. - 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. - Build leg 2. Bob’s GRIM builds the Standard-2 reply.
- Reply. Bob’s wallet gift-wraps and publishes it back to Alice’s relays, over Nym.
- Alice ingests the reply. Her wallet matches the Standard-2 to her pending tx and confirms it came from Bob’s
npub→FinalizePost. - Finalize. Alice’s GRIM finalizes the transaction.
- Broadcast. The finalized tx is posted to the Grin node directly (public chain data; not over the mixnet).
- 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.
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.
Where it’s wired
- UI dispatch:
goblin/src/gui/views/goblin/send.rs→WalletTask::NostrSend/NostrRequest/NostrPayRequest. - Task handling + finalize/broadcast:
goblin/src/wallet/wallet.rs(theWalletTask::Nostr*arm, guarded wrappersnostr_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 / @handle resolves via NIP-05 → a verified card (
name@domain). - A pasted
npub/hex/nprofiletriggers akind 0profile 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.)
- A name / @handle resolves via NIP-05 → a verified card (
- 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
subjecttag. - 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.
- QR / scan-to-pay. The recipient row and the home header offer a camera scanner; “My Code” shows your own
nprofileQR so someone can scan to pay you. See QR & camera.
Requests reuse the surface: 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.
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).
Reference
In goblin/src/gui/views/goblin/send.rs:
SendFlow+Stageenum;Recipient,Candidate,LookupResulttypes.- Debounced lookup → NIP-05 resolve /
kind 0fetch; the unverified-key confirm gate. request: boolswitches Pay ↔ Request; scan viaCameraContent+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
- What happens after you hold-to-send: The payment flow.
- Avatars on the cards: Avatars & identicons.
The NIP-05 name authority
Summary. Usernames like
alice@goblin.stcome from a small, self-hostable service (goblin-nip05d) that implements NIP-05 resolution and NIP-98-authenticated registration. Goblin ships withgoblin.stas the default authority, but it’s configurable: anyone can run their own and Goblin can point at it (federation).
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.stfetcheshttps://goblin.st/.well-known/nostr.json?name=alice(over the mixnet) and reads the pubkey (and any relay hints). A reverse lookup (name-by-pubkey) lets a wallet show the@namefor a key it only knows bynpub. - 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
@aliceafter rotation. - Federation. The authority is just a host. Settings → Identity → Name authority lets you change it; bare names then resolve against your chosen domain, and foreign
name@otherdomainidentifiers resolve against their domain. Goblin only auto-trusts its own domain’s names; others pass through the unverified-key gate.
goblin.st) and the claim-username panel, dark.Reference
- Client side (
goblin/src/nostr/nip05.rs):split_identifier()(parseuser@domain/@user/ bare),is_valid_hostname(),resolve(),name_by_pubkey()(reverse),verify(),Nip05Check(Verified/Mismatch/Unreachable);set_home_domain()/home_domain(); defaultsHOME_NIP05_DOMAIN = "goblin.st",DEFAULT_NIP05_SERVER. - Server side (
goblin-nip05d/, a sibling Axum + SQLite crate): the.well-known/nostr.jsonendpoint,/api/v1name availability / register / release / transfer / by-pubkey, NIP-98 auth, reserved names, cooldown. It bundles a stockstrfryrelay write-policy and is deployed atgoblin.stbut designed to be self-hosted. - UI: claim/rotate/transfer flows in
goblin/src/gui/views/goblin/mod.rs(ClaimState,RotateState,NameAuthorityState); availability mapped to friendly copy viaavailability_feedback().
References
- NIP-05 (DNS-based names): https://nips.nostr.com/5.
- NIP-98 (HTTP auth): https://nips.nostr.com/98.
- Self-hosting: Run a name authority.
Onboarding
Summary. First run walks you from nothing to a funded, named wallet: pick how you connect to Grin, create or restore a wallet, confirm your recovery phrase, and optionally claim a username, with a prominent skip so you can stay anonymous.
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:
- Intro: what Goblin is (private, pay-by-username).
- Node: connect to a node, either Instant (a default external node, ready immediately) or Private (run the integrated node and sync yourself). This choice now also lives in Settings/Advanced.
- Wallet setup: name + password, or choose restore.
- Recovery phrase: generate (12–24 words) or import. Import supports paste and a SeedQR scan. This step uses GRIM’s
MnemonicSetupword grid and validation. - Confirm words: verify the phrase by re-entering it.
- 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 seed gives you a fresh random nostr identity by default; you bring your old one back via Import.
Reference
In goblin/src/gui/views/goblin/onboarding.rs:
OnboardingContent+Stepenum (Intro → Node → WalletSetup → Words → ConfirmWords → Identity).OnbImport: optional identity import (nsec / backup, with password when sealed), async worker result.- Reuses GRIM
MnemonicSetup.word_list_ui(madepub(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
- The identity it sets up: Identity.
- The username it can claim: Name authority.
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).
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.
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_confirmstate;WalletTask::NostrCancelSend). Gating usescancel_grace_secsfrom config. - Decline button on the request card:
goblin/src/gui/views/goblin/mod.rs(request row;decline_button());WalletTask::NostrDeclineRequest. Outgoing-request cancel isWalletTask::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)inwidgets.rs(transparent fill,t.lineborder,t.textink).
References
- Why a request is never auto-paid in the first place: Ingest policy.
- The states a payment moves through: Storage, config & types.
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,
#FFD60Aaccent on#0E0E0Cink) so it feels like the app.
Reference
goblin/src/gui/theme.rs:ThemeKind(Light/Dark/Yellow),ThemeTokens, theLIGHT/DARK/YELLOWpalettes, the avatar pairs, andtheme::tokens()/ink_for()helpers.- Picker: appearance section in
goblin/src/gui/views/goblin/mod.rssettings.
References
- Avatar colors come from these pairs: Avatars & identicons.
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
- The color pairs: Theme.
- Where avatars appear: Send & request.
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. 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; anostr:prefix is stripped before resolving. - 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.
Reference
goblin/src/gui/views/camera.rs:CameraContent, thenokhwacapture +rqrrdecode, UR reassembly, the unavailable-camera timeout.goblin/src/gui/views/qr.rs:QrCodeContentgeneration (qrcodegen), animated UR output.- Scan entry points + accepted payloads:
goblin/src/gui/views/goblin/send.rs(scan,ScanTab).
References
- The codes it shows: Send & request, Identity (
nprofile).
Localization
Summary. Every user-facing string in the Goblin surface goes through translation keys. Six locales ship today (English, German, French, Russian, Turkish, Simplified Chinese), 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 six files (en, de, fr, ru, tr, zh-CN) 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. Chinese is auto-detected from the system locale.
For example, the recent UI change that renamed the relay row added a goblin.settings.nostr_relays key to all six files at once; the parity test is what guarantees that.
Reference
goblin/locales/{en,de,fr,ru,tr,zh-CN}.yml: the string tree.goblin/tests/i18n_keys.rs:every_locale_has_all_goblin_keys(the drift test).- Usage:
t!("…")call sites throughoutgoblin/src/gui/views/goblin/.
References
- Contributing a locale: Building Goblin.
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 routing everything over the mixnet. 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
| Measure | What it prevents | Where |
|---|---|---|
| Requests are never auto-paid | A stranger draining you with an Invoice-1 | Ingest policy (decide() → SurfaceRequest) |
| Replies bound to counterparty + pending tx | A forged Standard-2/Invoice-2 finalizing something | Ingest policy |
| Size ceilings (64 K / 32 K / 30 K / 256) | Memory-blow-up / DoS via huge messages | Protocol constants |
| Encrypted key at rest | Offline key theft; password grinding | Identity: NIP-49 ncryptsec, scrypt log_N=16, 0600 |
| Processed-id archive + 30-day TTL | Replaying an old payment message | Storage (processed db) |
| NIP-98 single-use auth | Replaying a name registration request | Name authority |
| Per-sender rate limits | Spam flooding from one key | NostrService (contact 30/h, unknown 10/h) |
| Everything over Nym | IP exposure + timing correlation | Nym |
| Reserved names, homograph folding, cooldown | Impersonation / squatting on names | Name authority |
| Tag-independent classification | A sender lying about message type via tags | Protocol (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 Goblin clients share mixnet 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.rsand 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.stname authority, the Goblin relay, and a Nym network requester) is all run-your-own. None of it is a hard dependency: you can point a Goblin wallet at your own name authority, your own relay, and your own mixnet exit, 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
| Service | What it does | Guide |
|---|---|---|
Name authority (goblin-nip05d) | Issues @names, resolves NIP-05, NIP-98 auth | Run a name authority |
Relay (strfry + write policy) | Carries the encrypted payment messages | Run a relay |
| Nym network requester | The mixnet exit Goblin egresses through | Run a Nym network requester |
| The app itself | Build for desktop / Android | Building 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. - Mixnet exit: set the
GOBLIN_NYM_PROVIDERenvironment variable to your requester’s address (see the client).
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-nip05dis a small, self-hostable Axum + SQLite service that issuesname@yourdomainidentities and resolves them via NIP-05, with NIP-98-authenticated self-service registration. Running your own makes you an independent issuer;goblin.stis just one operator.
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 viaGOBLIN_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.). SetNIP05_DBto your state path.nginx.conf.example/Caddyfile: TLS termination that proxies/.well-known/nostr.jsonand/api/to the service (on its loopback port) withX-Real-IPset, and the relay websocket tostrfry.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
Then point a wallet at it: Settings → Identity → Name authority → yourdomain.
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,DELETErelease, transfer, andby-pubkeyreverse lookup. - Deploy templates:
goblin-nip05d/deploy/{goblin-nip05d.service,nginx.conf.example,Caddyfile,strfry}. - Client side: The NIP-05 name authority.
References
- NIP-05: https://nips.nostr.com/5; NIP-98: https://nips.nostr.com/98.
Run a relay
Summary. Goblin’s payment messages ride ordinary Nostr relays, so any relay works. The Goblin default relay is a 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://, through the Nym mixnet: from the relay’s perspective connections arrive from mixnet exit IPs, 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.
Deploying
The write-policy and a Compose/relay setup live under goblin-nip05d/deploy/strfry/. The fastest path is the bundled Docker Compose (relay + name authority + auto-HTTPS); to run strfry standalone, install it per its upstream docs and add the Goblin write-policy. Then advertise the relay in your wallet: Settings → Nostr Relays → add wss://relay.yourdomain → Save & reconnect.
Reference
- Write policy + deployment:
goblin-nip05d/deploy/strfry/. - Allowed kinds rationale: Relays, Protocol.
- strfry upstream: https://github.com/hoytech/strfry.
References
- How clients connect: Relay traffic over the mixnet.
Run a Nym network requester
Summary. Goblin egresses the mixnet through a network requester: the exit that forwards your traffic to relays and HTTPS hosts. Goblin ships with a default, but you can run your own for reliability and point wallets at it with one environment variable.
Motivation
The network requester is the last mixnet hop before the open internet. Using a requester you operate means you’re not depending on a third party’s exit for your community’s payments, and you can keep it close to the relays and name authority you also run. Goblin uses the standard requester (with a normal exit policy) rather than an open proxy.
How it works
A nym-network-requester is initialized once (it registers with a gateway and gets a mixnet address), then run as a long-lived service. Its address is what wallets target. A wallet picks the requester from either:
- the baked-in default (
NETWORK_REQUESTERingoblin/src/nym/sidecar.rs), or - the
GOBLIN_NYM_PROVIDERenvironment variable at runtime (overrides the default).
Deploying
# 1. build or obtain a portable nym-network-requester binary
# 2. initialize (registers with a gateway; standard exit policy, NOT an open proxy)
nym-network-requester init --id my-requester
# 3. run it as a service (systemd unit, restart on failure)
nym-network-requester run --id my-requester
# 4. note the printed mixnet address; that's the "provider"
Then either set GOBLIN_NYM_PROVIDER=<address> in the wallet’s environment, or bake it into NETWORK_REQUESTER and rebuild. The project includes a reference deploy script (deploy-nym-requester.sh) that builds a portable (glibc-2.17) binary, installs a systemd unit, initializes the requester, and prints its address; adapt the host specifics to your own server.
Reference
- Where the address is consumed:
goblin/src/nym/sidecar.rs(NETWORK_REQUESTER,provider(),GOBLIN_NYM_PROVIDER). - Reference deploy automation:
deploy-nym-requester.sh(project root). - The in-process client that dials it: The in-process mixnet client.
References
- Nym network requester docs: https://nym.com/docs.
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.shsets up what’s needed. - For Android: the Android SDK + NDK and
cargo-ndk(driven bygoblin/scripts/android.sh). - For the name authority / relay: build those from the
goblin-nip05dcrate 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.
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
- The engine you’re building: GRIM base.