docs: TRANSACTIONS.md — full payment lifecycle, statuses, ingest guard, cancel/expiry/recovery
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user