18 KiB
Goblin transactions — how a payment works, end to end
This document explains the full lifecycle of a Goblin payment: how money moves,
every status it passes through, and the small guarantees that keep it safe. It is
written against the code in src/nostr/ and src/wallet/ — function names and
files are cited (line numbers drift, so they aren't).
1. The big picture: two layers
A Goblin payment is a Grin transaction wrapped in a private nostr message.
-
Grin layer (the money). Grin/Mimblewimble transactions are interactive: the sender and recipient exchange a "slate" that passes through states until it's finalized and posted on-chain. There are no addresses and no amounts on the chain. Goblin reuses GRIM's full Grin node + wallet engine for this.
-
Nostr layer (the delivery). Instead of making you hand slate files back and forth, Goblin delivers each slate as an end-to-end-encrypted nostr direct message, routed through the Nym mixnet. You pay a
usernameornpub; the recipient's wallet applies the slate automatically.
The slate is the payload; nostr is the transport. Everything below is about how those two layers move together and what state is tracked at each step.
Slate states (Grin layer)
Interactive Grin slates pass through numbered states. Goblin uses two flows:
| Flow | States | Who builds what |
|---|---|---|
| Standard (sender pushes money) | Standard1 → Standard2 → Standard3 |
Sender builds S1 (locks their outputs), recipient replies S2, sender finalizes S3 and posts |
| Invoice (recipient pulls money) | Invoice1 → Invoice2 → Invoice3 |
Requester builds I1 (the ask), payer replies I2 (pays), requester finalizes I3 and posts |
Status + direction (Goblin's nostr metadata)
For each payment Goblin stores a TxNostrMeta (src/nostr/types.rs) keyed by
slate id, with a direction and a status:
NostrTxDirection:
Sent— we pushed funds (we created S1).Received— we were paid (we replied S2).RequestedByUs— we issued an invoice (we created I1).RequestedOfUs— someone invoiced us and we paid it (we replied I2).
NostrSendStatus:
Created— slate built locally, DM not dispatched yet (durable checkpoint).AwaitingS2— S1 sent, waiting for the recipient's S2 reply.ReceivedNoReply— we processed an incoming S1 (or I1) and built our reply, but haven't dispatched it yet (crash-recovery point).RepliedS2— our S2 reply was dispatched (we received a payment).AwaitingI2— our I1 invoice was sent, waiting for the payer's I2.PaidAwaitingFinalize— we paid an invoice (sent I2); the requester finalizes.Finalized— slate finalized and posted on-chain.SendFailed— DM dispatch failed; eligible for retry.Cancelled— cancelled locally (manual cancel or 24h expiry).
Finalized and Cancelled are terminal.
2. Identity & addressing
Your nostr identity is a key that is deliberately not derived from your wallet
seed (src/nostr/identity.rs) — so you can rotate it any time to stay
unlinkable without ever touching your funds. It's stored encrypted at rest
(NIP-49 ncryptsec under your wallet password).
You can optionally claim a human-readable username from a name authority
(a NIP-05 server). The authority is configurable (Settings → Identity → Name
authority; NostrConfig::{nip05_server, home_domain, set_nip05_server}), which is
what makes Goblin federated: a user on bob@otherinstance.com can pay
alice@goblin.st, because a full name@domain always resolves against that
domain's /.well-known/nostr.json. Bare names (alice) resolve against your
configured home authority.
Display rules (data::display_name, no @ ever shown):
- A local petname wins.
- A verified name on your home authority shows bare (
alice) + a check. - A verified name on a foreign authority shows
alice · domain+ a check, so it can never masquerade as a home name. - Otherwise: a short npub.
Names are kept fresh: see §11 Name freshness.
3. Transport: NIP-17 gift wraps over Nym
A payment DM is built and sent by send_payment_dm; control messages (voids) by
send_control_dm (both in src/nostr/client.rs). The message structure
(src/nostr/protocol.rs):
- The payload is the raw Grin slatepack armor (
BEGINSLATEPACK… ENDSLATEPACK) inside a kind-14 rumor, prefixed with a human preamble ([Goblin] GRIN payment message — open in Goblin …) so a non-Goblin nostr client shows something sane. - Tags: a
["goblin","1"]protocol marker always; an optional["subject", …]carrying the user's note (sanitized); control DMs carry["goblin-action","void", slate_id]and no slatepack. - The rumor is sealed and wrapped as a kind-1059 gift wrap (NIP-59 + NIP-44
encryption) via nostr-sdk's
send_private_msg_to. Relays only ever see ciphertext — never the amount, sender, or recipient.
Where it's delivered: the recipient's kind-10050 DM-relay list
(fetch_dm_relays), with our own default relays as fallback, plus any relay
hints carried by a pasted nprofile. Default relays: relay.goblin.st,
relay.damus.io, nos.lol (src/nostr/relays.rs), capped at MAX_DM_RELAYS.
How relays are reached: every relay connection runs through the in-process
Nym mixnet SOCKS5 proxy (NymWebSocketTransport; run_service waits for the
proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the
network layer. The Grin node connection (block sync + broadcasting the final tx)
is direct clearnet — it's public chain data, the same for everyone, not tied to
your identity.
The UI tracks an outgoing attempt via a coarse send phase
(client::send_phase): IDLE / WORKING / SENT / FAILED / REQUEST_BLOCKED, with a
human-readable failure reason on FAILED.
4. Flow A — Pay by username/npub (Standard, we send)
Dispatched as WalletTask::NostrSend(amount, npub, note, relay_hints); handled in
wallet.rs.
w.send(amount)builds the S1 slate and locks our outputs (the funds are reserved but not yet spent).- Save meta
Createdbefore any network call — this is the durable point a crash recovers from. send_payment_dmdelivers S1 →AwaitingS2(storing the gift-wrap event id). On dispatch failure →SendFailed(retryable). On success the contact is created/refreshed (so people you pay appear in Suggested) and a background NIP-05 lookup resolves their name.- The recipient replies S2 (Flow B). When their S2 gift wrap arrives, the ingest
guard routes it to
nostr_finalize_post, which finalizes S3 and posts it on-chain →Finalized.
Created ──(S1 sent)──▶ AwaitingS2 ──(their S2 arrives)──▶ Finalized
└──(dispatch fails)──▶ SendFailed ──(retry)──▶ AwaitingS2
└──(manual cancel / 24h expiry)──▶ Cancelled (outputs unlocked)
5. Flow B — Receiving (Standard, we're paid)
Our service subscribes to kind-1059 gift wraps addressed to us
(run_service). When an S1 arrives, handle_wrap runs the ingest pipeline
(§7) and decide() classifies it by the accept policy:
Everyone→ AutoReceive (auto-reply S2).Contacts→ AutoReceive if the sender is a known contact, else SurfaceIncoming (an approval card).Ask→ always SurfaceIncoming.
AutoReceive: nostr_receive builds the S2 reply; we save meta
Received / ReceivedNoReply, mark the message processed, then dispatch S2 →
RepliedS2. If the S2 dispatch fails we stay at ReceivedNoReply and resend
on the next start (§9). The sender then finalizes S3 (Flow A step 4).
(incoming S1) ──▶ ReceivedNoReply ──(S2 dispatched)──▶ RepliedS2
└──(dispatch fails)──▶ stays ReceivedNoReply → resent on restart
SurfaceIncoming instead stores a PaymentRequest (status Pending) for the
user to approve or decline — see Flow D.
6. Flow C — Request money (Invoice)
We request — WalletTask::NostrRequest(amount, npub, note, …):
- First we check the recipient hasn't opted out:
accepts_requestsreads their kind-0goblin_accepts_requestsfield; an explicitfalse→ phaseREQUEST_BLOCKEDand we stop (fail-open: unknown/unreachable = allowed). issue_invoice(amount)builds I1 (no outputs locked — it's just an ask).- Save meta
RequestedByUs / Created, dispatch I1 →AwaitingI2. - When the payer's I2 arrives, the ingest guard finalizes I3 and posts →
Finalized.
They approve & pay (the other side of the same flow) is Flow D.
Created ──(I1 sent)──▶ AwaitingI2 ──(their I2 arrives)──▶ Finalized
└──(SendFailed → retry) └──(cancel / expiry)──▶ Cancelled
7. Flow D — Approving an incoming request (we pay an invoice)
Someone's I1 is always surfaced as a PaymentRequest, never auto-paid
(a hard security invariant). It shows in the Requests list. The user can:
- Approve →
WalletTask::NostrPayRequest(rumor_id): re-parse the stored slatepack (must still be I1),nostr_paybuilds I2 (this is where we pay), save metaRequestedOfUs / ReceivedNoReply, dispatch I2 →PaidAwaitingFinalize, mark the requestApproved. The requester then finalizes I3. A "Paying…" spinner shows while this runs; the card clears on success. - Decline →
WalletTask::NostrDeclineRequest(rumor_id): markDeclinedand send a void control DM so the requester's side clears too.
A surfaced incoming Standard S1 (from SurfaceIncoming) is approved the same way,
but routes through nostr_receive (Flow B) rather than nostr_pay.
RequestStatus: Pending → Approved | Declined | Expired | Cancelled
(Cancelled = the requester withdrew it via a void).
8. The ingest guard — decide()
Every incoming gift wrap is judged by decide() (src/nostr/ingest.rs), a
positive allow-list: anything not explicitly accepted is Dropped. This is
the security core. Summary:
| Incoming state | Condition | Decision |
|---|---|---|
Standard1 |
amount 0, or slate already known | Drop |
Standard1 |
new, policy Everyone (or Contacts + known) |
AutoReceive |
Standard1 |
new, policy Contacts + unknown, or Ask |
SurfaceIncoming |
Standard2 |
matches our pending Sent tx (status AwaitingS2/Created/SendFailed) and sender == stored counterparty and the tx exists |
FinalizePost |
Standard2 |
sender mismatch, or status Cancelled/Finalized, or no meta |
Drop |
Invoice1 |
amount 0, already known, or incoming-requests disabled | Drop |
Invoice1 |
otherwise | SurfaceRequest (never auto-pay) |
Invoice2 |
matches our pending RequestedByUs tx + sender match |
FinalizePost |
Invoice3 / unknown |
— | Drop |
Key consequences:
- A late S2 on a cancelled send falls through to
Drop— so cancelling is safe even if the recipient's reply is still in flight (the cancel marks the metaCancelledfirst, anddecide()then drops the S2). - Finalize only happens for a slate we are actually waiting on, from the exact key we sent to.
- Invoices are never auto-paid.
9. Cancel & reclaim
A stuck outgoing send (recipient never replied) locks your outputs. You can reclaim them manually from the receipt's "Cancel payment" button, or the 24h auto-expiry does it for you (§10).
WalletTask::NostrCancelSend(slate_id) (wallet.rs):
- Take the
cancel_finalize_lock— this serializes against a concurrent S2 finalize so the two can't both win (cancel-and-post would be a double action). - Re-check live state under the lock. If the tx is already
Finalized, or confirmed/posted on-chain → do nothing and returnCancelOutcome::AlreadyCompleted("This payment already went through and can't be cancelled"). If alreadyCancelled→ idempotent success. - Otherwise mark the meta
Cancelledfirst, thenw.cancel(tx_id)to unlock the Grin outputs, then best-effort send a void control DM to the recipient (they're likely offline). →CancelOutcome::Cancelled("your funds are available again").
Receipt button visibility (cancelable_send gate): shown only while the send
is still unanswered — direction Sent, status in {Created, AwaitingS2, SendFailed}, not confirmed, not already cancelled, and either it never
reached a relay (SendFailed, shown immediately) or the grace window
(cancel_grace_secs, default 600s) has passed. The instant the recipient accepts
(status leaves that set) the button disappears.
Recipient side / void ordering: a void control message marks the slate so that
if the recipient's wallet later (or earlier) sees the S1, it's dropped — including
the void-before-S1 race, where the void arrives first and is recorded as
void:{slate_id}:{sender} so the subsequent S1 is dropped.
There are sibling tasks for the other directions: NostrCancelOutgoing (withdraw
an invoice we issued) and NostrDeclineRequest (decline an incoming request) —
both send a void and mark the local record.
10. Auto-expiry (24h)
expire_stale (src/nostr/client.rs) runs from the sync loop. Any non-terminal
meta older than expiry_secs (default 24h) is expired:
- If it locked our outputs (
expiry_locks_outputs: aSentsend inCreated/AwaitingS2/SendFailed, or aRequestedOfUsinvoice we paid inPaidAwaitingFinalize) → cancel the Grin tx to unlock, and mark metaCancelled. - If it locked nothing of ours (incoming payments, invoices we issued) → just
annotate
Cancelled. - Pending incoming
PaymentRequests flip toExpired.
This is the same unlock path as manual cancel; the manual button just lets you act before the 24h.
11. Crash recovery (reconcile)
On service start, reconcile (client.rs) re-dispatches any pending outgoing
message within a 7-day window, by (direction, status):
| Direction · status | Slate | Action |
|---|---|---|
Sent · Created/SendFailed |
Standard1 | resend S1 → AwaitingS2 |
RequestedByUs · Created/SendFailed |
Invoice1 | resend I1 → AwaitingI2 |
Received · ReceivedNoReply |
Standard2 | resend S2 → RepliedS2 |
RequestedOfUs · ReceivedNoReply |
Invoice2 | resend I2 → PaidAwaitingFinalize |
Because the slatepack text is persisted and the meta is written before every dispatch, a crash at any point is recoverable: re-sending an already-delivered message is harmless (the peer dedups it; see §12).
12. Confirmations (X / N)
A posted Grin tx matures over min_confirmations blocks (default 10) before it's
spendable. Grin marks a tx confirmed at the first block, but Goblin's
receipt counts toward the spendable threshold so the number actually moves
(data::receipt_detail):
- broadcast, no block yet →
0 / N - on-chain, immature →
count / Nwherecount = tip − inclusion_height + 1 count ≥ N→ matured (shown as complete; the receipt's network-fee row is shown only for outgoing payments — a recipient pays no fee).
13. Reliability primitives
- Dedup / processed markers (
store::{is_processed, mark_processed, prune_processed}): every wrap is recorded at three levels — the gift-wrap event id, the inner rumor id, andslate:{id}:{state}— so a replayed or re-sent message is processed exactly once. Markers TTL out after 30 days (pruned on start + hourly). - Rate limiting (
allow_sender): per-sender sliding window — 30 events/hour for known contacts, 10/hour for unknowns — plus a global decrypt ceiling (~120 NIP-44 unwraps/min) to bound CPU/battery against fresh-keypair spam. A message dropped purely for the global ceiling isn't marked processed, so it can be retried later. - Seal integrity: the gift-wrap seal signer must equal the inner rumor author, and self-addressed messages are dropped.
- The cancel/finalize lock (§9) prevents a cancel and a finalize from both succeeding on the same slate.
14. Name freshness (contacts)
Cached @usernames are re-validated against the name authority on a periodic
sweep (NAME_REVERIFY_INTERVAL_SECS, ~78s, capped per tick), and once at app open
(persisted last_name_sweep_at, gated to the interval).
nip05::check returns Verified / Mismatch / Unreachable: a name is only
cleared (falls back to the npub) on a definitive Mismatch (the server says
it's gone or now maps to a different key) — never on a transient network failure.
This catches released or reassigned names and stops a freed name from
impersonating someone. A user-set petname is never touched.
15. File map
| Concern | File |
|---|---|
| Status / direction / meta types | src/nostr/types.rs |
| Gift-wrap + control message build/parse | src/nostr/protocol.rs |
| Service loop, send/receive/finalize, expiry, reconcile, name sweep | src/nostr/client.rs |
| Ingest allow-list | src/nostr/ingest.rs |
| Wallet task handlers (NostrSend / Request / PayRequest / CancelSend / finalize) | src/wallet/wallet.rs |
| Task definitions | src/wallet/types.rs |
| Metadata + dedup + contacts store | src/nostr/store.rs |
| NIP-05 resolve / verify / register, name authority | src/nostr/nip05.rs |
| Identity (key, NIP-49 backup) | src/nostr/identity.rs |
| Receipt / activity / confirmations / display name | src/gui/views/goblin/data.rs |
| Relay defaults + name-authority defaults | src/nostr/relays.rs |
🤖 Documentation written with AI pair-programming assistance (Claude).