From 24abc7e7b3b61898b28b69dba74b677888a4c991 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:55:51 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20TRANSACTIONS.md=20=E2=80=94=20full=20pa?= =?UTF-8?q?yment=20lifecycle,=20statuses,=20ingest=20guard,=20cancel/expir?= =?UTF-8?q?y/recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TRANSACTIONS.md | 377 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/TRANSACTIONS.md diff --git a/docs/TRANSACTIONS.md b/docs/TRANSACTIONS.md new file mode 100644 index 00000000..847d3b29 --- /dev/null +++ b/docs/TRANSACTIONS.md @@ -0,0 +1,377 @@ +# 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) | `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](#11-contacts--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**: + +- `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, …)`: + +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: + +- **Approve** → `WalletTask::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. +- **Decline** → `WalletTask::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 `Drop`ped. 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 `PaymentRequest`s 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).