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

380 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 **Tor**. 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 Tor
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 an in-process
**Tor** client (arti, linked directly into the wallet binary — no sidecar), via
`TorWebSocketTransport` (`run_service` waits for Tor to be ready before dialing).
So the relay never sees your IP: the money-path relay is dialed at its pinned
`.onion` address, and any relay without one is reached over a Tor exit to its
clearnet host. 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).