1
0
forked from GRIN/grim
Files
goblin/docs/TRANSACTIONS.md

18 KiB
Raw Permalink Blame History

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.

  1. 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.

  2. 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 username or npub; 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) Standard1Standard2Standard3 Sender builds S1 (locks their outputs), recipient replies S2, sender finalizes S3 and posts
Invoice (recipient pulls money) Invoice1Invoice2Invoice3 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.

  1. w.send(amount) builds the S1 slate and locks our outputs (the funds are reserved but not yet spent).
  2. Save meta Created before any network call — this is the durable point a crash recovers from.
  3. send_payment_dm delivers 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.
  4. 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:

  • EveryoneAutoReceive (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 requestWalletTask::NostrRequest(amount, npub, note, …):

  1. First we check the recipient hasn't opted out: accepts_requests reads their kind-0 goblin_accepts_requests field; an explicit false → phase REQUEST_BLOCKED and we stop (fail-open: unknown/unreachable = allowed).
  2. issue_invoice(amount) builds I1 (no outputs locked — it's just an ask).
  3. Save meta RequestedByUs / Created, dispatch I1 → AwaitingI2.
  4. 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:

  • ApproveWalletTask::NostrPayRequest(rumor_id): re-parse the stored slatepack (must still be I1), nostr_pay builds I2 (this is where we pay), save meta RequestedOfUs / ReceivedNoReply, dispatch I2 → PaidAwaitingFinalize, mark the request Approved. The requester then finalizes I3. A "Paying…" spinner shows while this runs; the card clears on success.
  • DeclineWalletTask::NostrDeclineRequest(rumor_id): mark Declined and 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 meta Cancelled first, and decide() 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):

  1. 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).
  2. Re-check live state under the lock. If the tx is already Finalized, or confirmed/posted on-chain → do nothing and return CancelOutcome::AlreadyCompleted ("This payment already went through and can't be cancelled"). If already Cancelled → idempotent success.
  3. Otherwise mark the meta Cancelled first, then w.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: a Sent send in Created/AwaitingS2/SendFailed, or a RequestedOfUs invoice we paid in PaidAwaitingFinalize) → cancel the Grin tx to unlock, and mark meta Cancelled.
  • If it locked nothing of ours (incoming payments, invoices we issued) → just annotate Cancelled.
  • Pending incoming PaymentRequests flip to Expired.

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 / N where count = 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, and slate:{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).