From f247057620b3bb8343f9b15eaac431a2f83c51ce Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 22 Aug 2026 11:09:17 -0300 Subject: [PATCH 1/4] docs: payment-account history / anti-triangulation implementation spec Turn the anti-triangulation specification (https://gist.github.com/grunch/bf70cb2b3cadaa85d4374c40ab17b8d3) into an engineering plan for mostrod, mostro-core and the protocol book: context and threat model, baseline facts about the current identity model (including the full-privacy-mode gap), locked design decisions, protocol surface, migration, handler sketches, success hook on the single Status::Success CAS, authorization/privacy analysis, client contract, test plan and PR breakdown. --- docs/PAYER_HISTORY_ANTI_TRIANGULATION.md | 1098 ++++++++++++++++++++++ docs/README.md | 1 + 2 files changed, 1099 insertions(+) create mode 100644 docs/PAYER_HISTORY_ANTI_TRIANGULATION.md diff --git a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md new file mode 100644 index 00000000..2d69a015 --- /dev/null +++ b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md @@ -0,0 +1,1098 @@ +# Payment Sender Verification & Payment-Account History — Anti-Triangulation Implementation Spec + +**Status:** Draft for review · **Target:** `main` (`mostro-core 0.14.5` → needs a +`mostro-core` minor release, see §6) · **Feature flag:** `[payer_history].enabled`, +defaults to `false` + +This document turns the +[anti-triangulation specification](https://gist.github.com/grunch/bf70cb2b3cadaa85d4374c40ab17b8d3) +into an engineering plan for `mostrod`, `mostro-core` and the protocol book. The +gist defines *what* the mechanism is and *why* it preserves Mostro's privacy +model; this document defines *how* it is built: the exact protocol surface, the +database schema, where each hook lands in the daemon, the authorization rules, +the client contract, the test plan and the PR breakdown. + +Read the gist first. Its numbered sections are referenced here as `gist §N`. + +--- + +## 1. Context — the problem we are attacking + +### 1.1 The triangulation scam + +Three participants: + +- **Seller** — a legitimate Mostro user selling sats. +- **Attacker** — a malicious Mostro buyer. +- **Victim** — an unrelated third party, outside Mostro. + +The attacker advertises something for sale outside Mostro (a phone, tickets, +furniture). The victim agrees to buy it. The attacker then takes a Mostro sell +order and, instead of paying the seller, gives the **seller's** fiat payment +details to the victim as "where to pay for the item". The victim pays the +seller. The seller sees the right amount at the right time, releases the sats +to the attacker, and is left holding a payment from somebody who never agreed +to buy bitcoin — and who will, sooner or later, dispute it with their bank or +report the seller for fraud. + +### 1.2 Why the obvious fixes are not enough + +| Mitigation | Why it fails (gist §5–§6) | +|---|---| +| A per-trade payment reference (`MOSTRO-8F21`) | The attacker simply tells the victim to use that reference. Possession of the reference proves nothing about *who* is paying. | +| Buyer declares the payer account, seller checks it matches | The attacker collects the victim's account details *before* taking the order and declares those. The incoming payment matches perfectly. | + +Declared details + a matching sender therefore **cannot** prove that the +sender is the Mostro buyer. They only prove that the buyer knew, in advance, +which account the money would come from. + +### 1.3 The asymmetry we can exploit + +A legitimate buyer reuses one or a handful of fiat accounts across many +trades. A triangulation attacker must burn a *fresh* third-party account on +(almost) every trade — that is their business model. So the question Mostro +can usefully answer is not + +> "Does this bank account belong to this person?" + +but + +> "Has **this Mostro user** already completed successful trades from **these +> same payment details**?" + +A positive answer (many successful trades, over months, against many different +counterparties) is expensive to fake. A negative answer is exactly what a +triangulation attack looks like — and it is also what every honest first-time +user looks like, which is why this is a **risk signal, never an automatic +block** (gist §20, §33). + +### 1.4 Two independent signals + +The seller's client ends up showing two separate checks before release: + +1. **Sender match** — does the fiat sender shown by the seller's bank match + the details the buyer declared? (Client-side, human-verified.) +2. **Payment-account history** — how much successful history does *this + buyer* have with *this payment identity*? (Computed privately by + `mostrod`, returned as three aggregate numbers.) + +A sophisticated attacker can satisfy (1). They cannot cheaply satisfy (2). + +--- + +## 2. Goals and non-goals + +### Goals +- Give the seller an aggregate, order-scoped history signal for the buyer's + declared payment identity: `successful_trades`, `distinct_counterparties`, + `first_success_at`, `last_success_at`. +- Let the seller's client verify that the payment details it received from the + buyer are the same ones the buyer committed to Mostro (hash consistency). +- Build history **only** from trades that reach `Status::Success`. +- Never publish payer details, payment hashes or history on Nostr. +- Never expose a "same user?" oracle or any cross-trade linkage to + counterparties (gist §12, §29). +- Keep `mostrod` byte-for-byte behaviour-preserving while the flag is off. +- Remain compatible with Full Privacy Mode (no crash, no leak, honest "no + history available" signal — see §4 D-4). + +### Non-goals +- Proving legal ownership of an account, KYC/AML, or any government identity. +- Deciding for the seller. Mostro provides information, not permission. +- Validating or canonicalising payment data **on the daemon**. Plaintext + payer data never reaches `mostrod` (§4 D-2). +- A structured payment-method registry in `mostro-core`. `payment_method` + stays the free-form string it is today; canonicalisation rules live in the + protocol book (§7). +- Cross-instance portability of history, privacy-preserving proofs, scoring + — future work (gist §37, §18 here). + +--- + +## 3. Baseline — how `mostrod` works today (facts this design builds on) + +Everything in this section is verified against `main` and `mostro-core 0.14.5`. +File references are to this repository unless marked `$CORE` +(`~/.cargo/registry/src/*/mostro-core-0.14.5/`). + +### 3.1 Identity model + +| Fact | Where | +|---|---| +| Every message carries a **trade key** (`event.sender`) and an **identity / master key** (`event.identity`). The master key is the only durable per-user handle the daemon has. | `$CORE/src/nip59.rs` (`UnwrappedMessage`), `src/app.rs:412-428` | +| `users.pubkey` (PRIMARY KEY) is the master key. A `users` row is created **only** when `identity != sender`. | `migrations/20231005195154_users.sql`, `src/app.rs:186-198` | +| `orders.master_buyer_pubkey` / `master_seller_pubkey` hold the identity keys; `buyer_pubkey` / `seller_pubkey` the trade keys. | `migrations/20221222153301_orders.sql`, `src/util.rs:630-646` | +| **Full Privacy Mode is structural, not a flag**: the client never sends the identity key, so `identity == sender` and `master_*_pubkey == *_pubkey`. No `users` row, no trade-index continuity, reputation reads as zero, rating a full-privacy counterpart is a silent no-op. | `$CORE/src/order.rs:186-195` (doc comment), `src/util.rs:228-254`, `src/app/rate_user.rs:109-125`, `$CORE/src/order.rs:496-514` (`is_full_privacy_order`) | + +**Consequence:** in Full Privacy Mode Mostro has *no* cross-trade continuity +for the buyer at all. The gist's "how Mostro identifies continuity between a +user's trade keys is an implementation detail" resolves, on this codebase, to: +*continuity = `master_buyer_pubkey`, which exists only in reputation mode.* + +### 3.2 What Mostro relays between the parties + +- The only counterparty data Mostro forwards today is a pubkey plus an optional + reputation snapshot, via `Payload::Peer(Peer { pubkey, reputation })` + (`src/app/fiat_sent.rs:44-74`, `src/util.rs:2241-2316`). +- There is **no** buyer→seller payment-info channel through Mostro. + `Action::SendDm` hits the `_` catch-all in `src/app.rs:263` and is ignored; + peer chat is a pure client feature (`$CORE/src/chat/`, protocol `chat.md`). +- `orders.payment_method` is an unvalidated free-form string, public on the + kind-38383 event as the multi-value `pm` tag (`src/nip33.rs:490-502`). +- `Action::FiatSent` carries no payload except the optional `NextTrade` + (`src/app/fiat_sent.rs:8-93`). + +### 3.3 Where "trade completed successfully" is known + +There is **exactly one** writer of `Status::Success` for a normal trade: +`payment_success` in `src/app/release.rs:1010-1070`. It performs a CAS +`UPDATE orders SET status=?, event_id=? WHERE id=? AND status='settled-hold-invoice'` +and treats `rows_affected()==0` as "another task already finalised this order — +do not repeat side effects". It is reached from the in-process payout watcher +(`release.rs:830`), from crash-recovery reconciliation (`release.rs:1162`) and, +via `do_payment`, from admin settle (`src/app/admin_settle.rs:243`) and the +retry job (`src/scheduler.rs:236`). + +The Cashu track defines a *different* success path — the release watcher in +`docs/cashu/03-track-b-release.md §5C` — which is not implemented yet. See §13. + +### 3.4 What is public on Nostr + +Kind 38383 (order) carries no pubkeys; kind 38384 (rating) is keyed by the rated +user's pubkey and carries only rating aggregates; kind 38385 (info) carries +node policy tags (e.g. `bond_policy_tags`, `src/nip33.rs:678`). Nothing about +fiat accounts exists on any event and this feature must keep it that way. + +### 3.5 Existing patterns we reuse + +| Pattern | Example | +|---|---| +| Party authorisation is open-coded per handler against `event.sender` | `src/app/fiat_sent.rs:25-27` (`InvalidPubkey`), `src/app/release.rs:237-239` (`InvalidPeer`) | +| Post-success, order-scoped, once-only authorisation | `src/app/rate_user.rs:69-206` + `claim_order_rating_flag` CAS (`src/db.rs:1446-1473`) | +| Opt-in feature section with `Option<…Settings>` and `#[serde(default)]` | `AntiAbuseBondSettings` (`src/config/types.rs:40`), `Settings.anti_abuse_bond` (`src/config/settings.rs:31`) | +| Feature-owned module with its own `db.rs` | `src/app/bond/{mod,db,model,flow,…}.rs` | +| Migration with a long `--` design header and per-column comments | `migrations/20260423120000_anti_abuse_bond.sql` | +| Node policy advertised on the info event | `bond_policy_tags` (`src/nip33.rs:678`) | +| Handler tests with in-memory sqlite + `TestContextBuilder` + `queued_actions_for(order_id)` | `src/app/fiat_sent.rs:95-311` | + +--- + +## 4. Locked design decisions (do not re-litigate per PR) + +**D-1 · History subject = `orders.master_buyer_pubkey`.** It is the only +durable per-user handle the daemon has (§3.1). No new identity mechanism is +introduced. + +**D-2 · Plaintext payer details never touch `mostrod`.** The buyer sends the +plaintext to the seller over the existing peer channel (protocol `chat.md`, or a +NIP-44 DM to the seller's trade key). Only the 32-byte `payment_hash` is sent +to Mostro. Mostro cannot leak, log or be subpoenaed for what it never receives. +This is stricter than gist §26 ("whenever possible store only the hash") and +costs nothing because the client already has a direct channel to the +counterparty. + +**D-3 · Hash consistency is enforced by the seller's client, assisted by +Mostro.** Mostro forwards the buyer-committed `payment_hash` to the seller. The +seller's client recomputes the hash from the plaintext it received off-band and +flags a mismatch locally. Mostro never compares plaintext to anything. + +**D-4 · Full Privacy Mode buyers get an honest "no history" answer, and nothing +is stored for them.** When `order.is_full_privacy_order()` reports the buyer +side as full-privacy, Mostro returns `buyer_mode: "full_privacy"` with zero +counters and **does not** write history rows (they could never be matched +again and would only link a hash to a trade key in the DB). Clients must render +this as *"history is unavailable for this buyer"*, not as *"new account"*. +Hash-only (user-agnostic) history is explicitly deferred to §18. + +**D-5 · History increments in exactly one place: the `Success` CAS in +`payment_success`.** The increment runs only when `rows_affected()==1`, inside +the same function, and is idempotent by construction (the declaration row is +consumed atomically, §10.4). Any future success path (Cashu watcher, §13) must +call the same helper. + +**D-6 · Disputed trades do not build history.** If `order.buyer_dispute` or +`order.seller_dispute` is set when the order reaches `Success`, the +declaration is discarded without incrementing. Rationale: a trade that needed a +solver is not clean evidence of a healthy buyer↔account relationship, and this +removes the cheapest "farm history while disputing" path. Revisit with data +(§17). + +**D-7 · Counterparties are stored as a keyed hash, not as pubkeys.** +`counterparty_id = sha256("mostro-payer-history-cp-v1" ‖ node_secret ‖ master_seller_pubkey)` +where `node_secret` is the node's Nostr secret key bytes. The history tables +alone cannot be joined back to `users`/`orders` without the node key, and a +full-privacy seller (whose master key is a fresh trade key) simply counts as a +new counterparty each time — which is the conservative direction. + +**D-8 · Push, then pull.** Mostro *pushes* the history to the seller right +after `fiat-sent-ok`. A seller→Mostro *query* action exists for session +restore and clients that missed the push; it returns the same object and is +subject to the same authorization (§11). The push makes the MVP useful with +zero new client-initiated traffic; the pull is cheap because it reuses the same +code path. + +**D-9 · Additive protocol surface only.** New `Action` variants, new `Payload` +variants, new `CantDoReason` variants. `SmallOrder` is **not** touched — it is +`#[serde(deny_unknown_fields)]` and positional (`$CORE/src/order.rs:560-620`), +so any new field would break every existing client. `FiatSentOk` keeps its +`Payload::Peer` unchanged. + +**D-10 · Off by default, inert when off.** With `[payer_history]` absent or +`enabled = false`: the new actions answer `cant-do invalid_action`, `fiat-sent` +is untouched, no table is written, no info tag is emitted beyond +`payer_history_enabled = false`. + +**D-11 · Mostro never blocks on history.** The only enforcement knob is +`require_declaration` (default `false`), which rejects `fiat-sent` when no +declaration exists. Even then the seller always keeps the final decision +(gist §20). + +**D-12 · Domain-separated hash.** `payment_hash = sha256("mostro-payer-v1|" ‖ canonical_payment_data)`. +The gist specifies a bare `sha256(canonical)`; the fixed prefix costs nothing +and guarantees the value is useless outside this protocol (no accidental reuse +as an identifier elsewhere). Clients must agree on this exact prefix (§7). + +--- + +## 5. Trade flow with the feature + +Sell-order flow (buyer is the taker; the maker-buyer flow is symmetric — what +matters is only *which side is the buyer*): + +``` + Buyer (B) mostrod (M) Seller (S) + | | | + | take-sell / add-invoice ... | ... hold invoice paid ... | + |<----------- Active ---------->|<----------- Active ------------->| + | | | + | [client] canonicalise payer | | + | details, h = payment_hash | | + | | | + | declare-payer {h, pm} ------>| validate, upsert declaration | + |<-- payer-declared {h, pm} ----|--- payer-declared {h, pm} ------>| (S learns h) + | | | + | ===== plaintext payer details, peer channel (chat / DM) ======>| (M never sees it) + | | | + | fiat-sent ------------------>| (optional: require declaration) | + |<-- fiat-sent-ok {peer} -------|--- fiat-sent-ok {peer} --------->| + | |--- payment-history {stats} ----->| PUSH (D-8) + | | | + | |<-- payment-history (query) ------| PULL, optional + | |--- payment-history {stats} ----->| + | | | + | | S checks: sender == declared? | + | | S reads history tiers | + | |<-- release (or dispute) --------| + | | | + |<-- purchase-completed --------| Success CAS ok ──► record_payer_success (D-5) +``` + +### 5.1 Status matrix + +| Action | Who | Allowed order status | Effect | +|---|---|---|---| +| `declare-payer` | buyer trade key | `WaitingPayment`, `WaitingBuyerInvoice`, `Active` | upsert declaration (last write wins) | +| `declare-payer` | buyer | `FiatSent` or later | `cant-do not_allowed_by_status` (frozen) | +| `payment-history` (push) | Mostro → seller | emitted inside `fiat_sent_action` after `fiat-sent-ok` | — | +| `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice`, `Success` | same object as push | +| success hook | daemon | `SettledHoldInvoice → Success` CAS | history += 1, declaration consumed | +| prune | scheduler | order in a terminal non-success status | declaration deleted | + +`Active` is the normal declaration window. The two `Waiting*` statuses are +allowed so a client can collect the payer details during the same screen where +it collects the invoice, before the hold invoice is paid; nothing is +irreversible at that point. + +--- + +## 6. Protocol changes (`mostro-core`) + +These require a `mostro-core` **minor** release (`0.15.0` or `0.14.6` per the +project's versioning habit); `mostrod` PRs then bump the dependency. All +additions follow the existing serde conventions: `Action` is `kebab-case`, +`Payload` and `CantDoReason` are `snake_case` +(`$CORE/src/message.rs:64`, `:682`, `$CORE/src/error.rs:26`). + +### 6.1 `Action` (`$CORE/src/message.rs`) + +```rust +/// Buyer → Mostro: commit to the payer identity (hash only) for this order. +DeclarePayer, +/// Mostro → buyer (ack) and Mostro → seller (forward): the committed hash. +PayerDeclared, +/// Seller → Mostro: request the aggregate history for this order. +/// Mostro → seller: the aggregate history (push after fiat-sent, or reply). +PaymentHistory, +``` + +Wire names: `declare-payer`, `payer-declared`, `payment-history`. Append at the +end of the enum (there is no wasm ABI on `Action`, but keeping declaration +order stable is house style). + +### 6.2 `Payload` (`$CORE/src/message.rs`) + +```rust +/// Buyer's commitment to the fiat payer identity for one order. +/// Carries only the hash; the plaintext goes buyer → seller off-band. +PayerDeclaration(PayerDeclaration), +/// Aggregate, order-scoped history of (buyer, payment_hash). +PaymentHistory(PaymentHistory), +``` + +```rust +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct PayerDeclaration { + /// sha256("mostro-payer-v1|" || canonical_payment_data), 64 lowercase hex. + pub payment_hash: String, + /// Free-form label chosen by the client, ≤ 64 bytes (e.g. "SEPA", "CVU"). + /// Informational; Mostro does not validate it against the order's `pm`. + pub payment_method: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +pub struct PaymentHistory { + /// Echo of the buyer-committed hash, so the seller's client can compare + /// it with the hash it computes from the plaintext it received. + pub payment_hash: String, + /// "reputation" — counters are meaningful. + /// "full_privacy" — counters are always zero; history is *unavailable*. + pub buyer_mode: BuyerMode, + /// Number of orders that reached `success` for (buyer, payment_hash). + pub successful_trades: u32, + /// Number of distinct sellers among those orders (keyed hash, see D-7). + pub distinct_counterparties: u32, + /// Unix seconds of the first / last successful trade; `None` when + /// `successful_trades == 0`. + pub first_success_at: Option, + pub last_success_at: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BuyerMode { Reputation, FullPrivacy } +``` + +Wire keys: `payer_declaration`, `payment_history`. + +### 6.3 `CantDoReason` (`$CORE/src/error.rs`) + +```rust +/// `payment_hash` is not 64 lowercase hex chars, or `payment_method` is empty / too long. +InvalidPaymentHash, +/// `fiat-sent` refused because the node requires a prior `declare-payer`. +PayerNotDeclared, +``` + +Wire names: `invalid_payment_hash`, `payer_not_declared`. Existing reasons are +reused for everything else: `invalid_action` (feature off), `not_found` (order), +`invalid_pubkey` (not the buyer), `invalid_peer` (not the seller), +`not_allowed_by_status`, `invalid_payload`. + +### 6.4 `MessageKind::verify()` matrix (`$CORE/src/message.rs:809-913`) + +The match is exhaustive on `Action`; add: + +| Action | Requires | +|---|---| +| `DeclarePayer` | `id.is_some()` and `payload == Some(Payload::PayerDeclaration(_))` | +| `PayerDeclared` | `id.is_some()`; payload `PayerDeclaration` (Mostro-authored, lenient) | +| `PaymentHistory` | `id.is_some()`; payload `None` (query) **or** `PaymentHistory(_)` (reply/push) | + +### 6.5 Wire examples (v2 transport, decrypted content tuple) + +Buyer → Mostro: + +```json +[ + {"order": {"version": 2, "request_id": 981231, "trade_index": 7, + "id": "4f1c…", "action": "declare-payer", + "payload": {"payer_declaration": { + "payment_hash": "9b0e…c1", + "payment_method": "CVU"}}}}, + "", + ["", ""] +] +``` + +Mostro → buyer (ack) and Mostro → seller (forward), identical body: + +```json +[ + {"order": {"version": 2, "request_id": 981231, "trade_index": null, + "id": "4f1c…", "action": "payer-declared", + "payload": {"payer_declaration": { + "payment_hash": "9b0e…c1", + "payment_method": "CVU"}}}}, + null, null +] +``` + +(The seller copy carries `request_id: null`.) + +Mostro → seller, pushed immediately after `fiat-sent-ok`: + +```json +[ + {"order": {"version": 2, "request_id": null, "trade_index": null, + "id": "4f1c…", "action": "payment-history", + "payload": {"payment_history": { + "payment_hash": "9b0e…c1", + "buyer_mode": "reputation", + "successful_trades": 47, + "distinct_counterparties": 29, + "first_success_at": 1762128000, + "last_success_at": 1787654321}}}}, + null, null +] +``` + +Seller → Mostro query: same wrapper, `"action": "payment-history"`, +`"payload": null`, signed by the seller's trade key. Reply as above with the +`request_id` echoed. + +Full-privacy buyer: + +```json +{"payment_history": {"payment_hash": "9b0e…c1", "buyer_mode": "full_privacy", + "successful_trades": 0, "distinct_counterparties": 0, + "first_success_at": null, "last_success_at": null}} +``` + +No declaration at all (feature on, buyer never declared): the push is **not** +sent; a query answers `cant-do not_found`. Clients treat "no `payment-history` +message by release time" as its own (bad) signal. + +--- + +## 7. Canonicalisation and `payment_hash` (client contract) + +The daemon never sees plaintext (D-2), so canonicalisation is a **client +contract** that must be identical across clients or the same account yields +different hashes and history silently fragments. It is therefore specified in +the protocol book (new chapter `payer_declaration.md`, §15), not in code here. +Summary of the contract: + +1. **Canonical string** = fields joined by `|`, in the fixed order defined per + method, each field normalised as: + - Unicode NFKC, then uppercase; + - strip all whitespace, hyphens, dots and slashes from *identifier* fields + (IBAN, CBU/CVU, PIX key, account number, tax id); + - collapse runs of whitespace to one space in *name* fields, trim; + - country codes ISO-3166 alpha-2, currency ISO-4217. +2. **Method prefix** = `|` (e.g. `AR|CVU`, `EU|SEPA`, + `BR|PIX`), so identical account numbers under different rails never + collide. +3. **Hash** = `sha256("mostro-payer-v1|" + canonical)` (D-12), hex, lowercase. +4. The hash MUST NOT include order id, trade key, timestamps or salt + (gist §9) — those would make it unique per trade and defeat history. + +Examples (canonical → hashed): + +``` +AR|CVU|0000003100012345678901|27123456789 +EU|SEPA|DE89370400440532013000|ALICE SMITH +BR|PIX|+5511999998888 +``` + +`DE89 3704 0044 0532 0130 00` and `DE89370400440532013000` canonicalise to the +same string. + +**Low-entropy warning.** A bank account + name is guessable by anyone who +already knows the account; the hash is not a secret, it is an *identifier that +must never be published*. This is exactly why D-2/D-10 keep it off Nostr and +why the node DB is the only place it lives. + +Methods that cannot expose a sender (cash, gift cards, vouchers) have no +canonical form; clients MUST NOT declare a payer for them and SHOULD tell the +seller that sender verification is unavailable (gist §34). + +--- + +## 8. Daemon — configuration and node policy advertisement + +### 8.1 `settings.tpl.toml` + +```toml +# Payment-account history / anti-triangulation (docs/PAYER_HISTORY_ANTI_TRIANGULATION.md). +# Opt-in, disabled by default. Mostro only ever stores a SHA-256 of the +# buyer's canonicalised payer details, keyed by the buyer's identity key. +# +# [payer_history] +# enabled = false +# # When true, `fiat-sent` is rejected with `payer_not_declared` unless the +# # buyer sent `declare-payer` first. Leave false unless your market relies +# # on sender-verifiable rails only. +# require_declaration = false +``` + +### 8.2 `src/config/types.rs` + +```rust +/// Payment-account history (anti-triangulation). Opt-in; when `enabled` +/// is false every code path added by this feature is inert. +/// See `docs/PAYER_HISTORY_ANTI_TRIANGULATION.md`. +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct PayerHistorySettings { + #[serde(default)] + pub enabled: bool, + /// Reject `fiat-sent` when no `declare-payer` was received for the order. + #[serde(default)] + pub require_declaration: bool, +} +``` + +`Settings` gains `#[serde(default)] pub payer_history: Option` +(`src/config/settings.rs`, next to `anti_abuse_bond`) and two helpers mirroring +`is_cashu_enabled()`: + +```rust +pub fn is_payer_history_enabled() -> bool +pub fn payer_declaration_required() -> bool // implies enabled +``` + +### 8.3 Info event (kind 38385) + +`info_to_tags` (`src/nip33.rs:572`) appends `payer_history_tags(settings)`: + +| Tag | Value | +|---|---| +| `payer_history_enabled` | `"true"` / `"false"` (always emitted) | +| `payer_declaration_required` | `"true"` / `"false"` (only when enabled) | + +Clients use these to decide whether to show the declaration screen at all. + +--- + +## 9. Daemon — data model + +One migration, `migrations/2026MMDD120000_payer_history.sql`, in the house +style (long `--` header explaining *why*, per-column comments). Three tables; +**no** change to `orders` or `users`. + +```sql +-- Per-order commitment. Short-lived: consumed on success, pruned on any other +-- terminal status. Holds the ONLY copy of a hash that is not yet history. +CREATE TABLE IF NOT EXISTS order_payer_declarations ( + order_id char(36) PRIMARY KEY NOT NULL, -- orders.id (uuid) + payment_hash char(64) NOT NULL, -- sha256 hex, lowercase + payment_method varchar(64) NOT NULL, -- client label, informational + declared_at integer NOT NULL -- unix secs of last upsert +); + +-- Aggregate history. One row per (buyer identity key, payment hash). +-- Written ONLY from the Success CAS in release::payment_success. +CREATE TABLE IF NOT EXISTS payer_history ( + user_pubkey char(64) NOT NULL, -- orders.master_buyer_pubkey (reputation mode only) + payment_hash char(64) NOT NULL, + payment_method varchar(64) NOT NULL, -- label seen on the most recent success + first_success_at integer NOT NULL, + last_success_at integer NOT NULL, + successful_trades integer NOT NULL DEFAULT 0, + PRIMARY KEY (user_pubkey, payment_hash) +); + +-- Distinct-counterparty set. counterparty_id is a keyed hash (D-7), never a pubkey. +CREATE TABLE IF NOT EXISTS payer_history_counterparties ( + user_pubkey char(64) NOT NULL, + payment_hash char(64) NOT NULL, + counterparty_id char(64) NOT NULL, + first_success_at integer NOT NULL, + PRIMARY KEY (user_pubkey, payment_hash, counterparty_id) +); +``` + +Notes +- `distinct_counterparties` is `COUNT(*)` on the third table; not denormalised + (the counterparty insert is `INSERT OR IGNORE`, so the count is exact). +- No foreign keys: `orders` rows outlive declarations, and the bond tables set + the precedent of not declaring FKs. +- Sizes are trivial (two 64-char keys per successful trade). +- **Retention:** declarations are ephemeral (§10.5). History rows are kept + indefinitely in the MVP; an admin RPC to purge a `user_pubkey` is listed in + §18. +- The existing migration reconciler (`src/db.rs:161-260`) only special-cases + `ADD COLUMN`; plain `CREATE TABLE IF NOT EXISTS` needs nothing extra. + +--- + +## 10. Daemon — handlers and hooks + +New module `src/app/payer/` (`mod.rs`, `db.rs`, `declare.rs`, `history.rs`, +`success.rs`), following `src/app/bond/`. All functions return +`Result<_, MostroError>` and map sqlx errors to +`MostroInternalErr(ServiceError::DbAccessError(..))`. + +### 10.1 Shared helpers (`src/app/payer/db.rs`) + +```rust +pub struct PayerDeclarationRow { pub order_id: Uuid, pub payment_hash: String, + pub payment_method: String, pub declared_at: i64 } + +pub async fn upsert_declaration(pool, order_id: Uuid, hash: &str, method: &str, now: i64) + -> Result<(), MostroError>; // INSERT … ON CONFLICT(order_id) DO UPDATE +pub async fn find_declaration(pool, order_id: Uuid) + -> Result, MostroError>; +pub async fn take_declaration<'e, E: sqlx::Executor<'e>>(exec: E, order_id: Uuid) + -> Result, MostroError>; // DELETE … RETURNING * (idempotency token) +pub async fn load_history(pool, user_pubkey: &str, hash: &str) + -> Result<(u32 /*trades*/, u32 /*cps*/, Option, Option), MostroError>; +pub async fn bump_history<'e, E>(exec: E, user_pubkey, hash, method, counterparty_id, now) + -> Result<(), MostroError>; // upsert payer_history + INSERT OR IGNORE counterparties +pub async fn prune_declarations_for_terminal_orders(pool) -> Result; +``` + +Validation helper, used by the handler *and* by `bump_history`: + +```rust +pub fn validate_payment_hash(h: &str) -> Result<(), MostroError> { + let ok = h.len() == 64 && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if ok { Ok(()) } else { Err(MostroCantDo(CantDoReason::InvalidPaymentHash)) } +} +``` + +### 10.2 `declare_payer_action` (`src/app/payer/declare.rs`) + +Wired in `handle_message_action_no_ln` (`src/app.rs:207-268`) as +`Action::DeclarePayer => declare_payer_action(ctx, msg, event, my_keys)`. + +```rust +pub async fn declare_payer_action(ctx: &AppContext, msg: Message, + event: &UnwrappedMessage, my_keys: &Keys) -> Result<(), MostroError> { + if !Settings::is_payer_history_enabled() { + return Err(MostroCantDo(CantDoReason::InvalidAction)); // D-10 + } + let order = get_order(&msg, ctx.pool()).await?; // not_found + if order.get_buyer_pubkey().ok() != Some(event.sender) { // same check as fiat_sent.rs:25 + return Err(MostroCantDo(CantDoReason::InvalidPubkey)); + } + let status = order.get_order_status()?; + if !matches!(status, Status::WaitingPayment | Status::WaitingBuyerInvoice | Status::Active) { + return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)); // frozen after fiat-sent + } + let decl = match msg.get_inner_message_kind().get_payload() { + Some(Payload::PayerDeclaration(d)) => d.clone(), + _ => return Err(MostroCantDo(CantDoReason::InvalidPayload)), + }; + validate_payment_hash(&decl.payment_hash)?; + if decl.payment_method.is_empty() || decl.payment_method.len() > 64 { + return Err(MostroCantDo(CantDoReason::InvalidPaymentHash)); + } + db::upsert_declaration(ctx.pool(), order.id, &decl.payment_hash, + &decl.payment_method, Timestamp::now().as_u64() as i64).await?; + + let request_id = msg.get_inner_message_kind().request_id; + let payload = Some(Payload::PayerDeclaration(decl)); + enqueue_order_msg(request_id, Some(order.id), Action::PayerDeclared, + payload.clone(), event.sender, None).await; // ack to buyer + if let Ok(seller) = order.get_seller_pubkey() { // forward hash to seller + enqueue_order_msg(None, Some(order.id), Action::PayerDeclared, + payload, seller, None).await; + } + Ok(()) +} +``` + +Design notes +- Re-declaration overwrites. The seller receives every version; the client + keeps the last one. There is no partial-update path. +- The seller may not exist yet in `WaitingBuyerInvoice` for a maker-buyer + order whose taker has not paid — `get_seller_pubkey()` failing is not an + error; the seller will get the hash with the `payment-history` push later. +- No spam-gate change: the sender is already in the known-keys lane as a + party to a non-terminal order (`src/db.rs:40-80`). + +### 10.3 Push from `fiat_sent_action` and the optional gate + +In `src/app/fiat_sent.rs`, after the `Active` status check and the buyer +check (`:19-27`) and **before** the status transition: + +```rust +if Settings::payer_declaration_required() + && payer::db::find_declaration(pool, order.id).await?.is_none() { + return Err(MostroCantDo(CantDoReason::PayerNotDeclared)); +} +``` + +After the two `FiatSentOk` enqueues (`:50-74`): + +```rust +if Settings::is_payer_history_enabled() { + if let Some(h) = payer::history::build_for_order(pool, ctx.keys(), &order_updated).await? { + enqueue_order_msg(None, Some(order_updated.id), Action::PaymentHistory, + Some(Payload::PaymentHistory(h)), seller_pubkey, None).await; + } +} +``` + +`build_for_order` returns `None` when there is no declaration (no push, see +§6.5). Any DB error here is logged and **must not** fail `fiat_sent_action` +after the status has already moved — wrap in `if let Err(e) … tracing::warn!` +rather than `?` once the transition is committed. + +### 10.4 `payment_history_action` — the seller query (`src/app/payer/history.rs`) + +Wired as `Action::PaymentHistory => payment_history_action(ctx, msg, event, my_keys)`. + +```rust +pub async fn payment_history_action(ctx, msg, event, _my_keys) -> Result<(), MostroError> { + if !Settings::is_payer_history_enabled() { return Err(MostroCantDo(CantDoReason::InvalidAction)); } + let order = get_order(&msg, ctx.pool()).await?; + if order.get_seller_pubkey().ok() != Some(event.sender) { // seller only, §11 + return Err(MostroCantDo(CantDoReason::InvalidPeer)); + } + if !matches!(order.get_order_status()?, + Status::FiatSent | Status::Dispute | Status::SettledHoldInvoice | Status::Success) { + return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)); + } + let history = build_for_order(ctx.pool(), ctx.keys(), &order).await? + .ok_or(MostroCantDo(CantDoReason::NotFound))?; // buyer never declared + enqueue_order_msg(msg.get_inner_message_kind().request_id, Some(order.id), + Action::PaymentHistory, Some(Payload::PaymentHistory(history)), event.sender, None).await; + Ok(()) +} + +pub async fn build_for_order(pool, node_keys: &Keys, order: &Order) + -> Result, MostroError> { + let Some(decl) = db::find_declaration(pool, order.id).await? else { return Ok(None) }; + // After Success the declaration is gone; fall back to the history row only + // when the order is already terminal-success (restore-session use case). + let (normal_buyer_idkey, _) = order.is_full_privacy_order()?; + let Some(user) = normal_buyer_idkey else { + return Ok(Some(PaymentHistory::unavailable(decl.payment_hash))); // D-4 + }; + let (trades, cps, first, last) = db::load_history(pool, &user, &decl.payment_hash).await?; + Ok(Some(PaymentHistory { payment_hash: decl.payment_hash, buyer_mode: BuyerMode::Reputation, + successful_trades: trades, distinct_counterparties: cps, + first_success_at: first, last_success_at: last })) +} +``` + +`Status::Success` is allowed for the query so a seller restoring a session can +still see what they saw at release time. Because the declaration row is consumed +on success (§10.5), `build_for_order` must handle that case: when the order is +`Success` and no declaration exists, answer `not_found` — the seller already +received the push at `fiat-sent` time and the value would now include the +trade just completed, which is a different (and confusing) number. Keep it +simple: *post-success queries return `not_found`.* (Listed in §17 as an open +question if client authors want otherwise.) + +### 10.5 Success hook (`src/app/payer/success.rs`) — **D-5** + +Called from `payment_success` (`src/app/release.rs:1010`) **only** on the +`rows_affected() == 1` branch, immediately after the CAS and before the +`PurchaseCompleted` enqueue: + +```rust +// release.rs, after the CAS succeeded: +if Settings::is_payer_history_enabled() { + if let Err(e) = payer::success::record_payer_success(pool, ctx.keys(), &order_updated).await { + tracing::warn!("payer history not recorded for order {}: {e}", order_updated.id); + } +} +``` + +```rust +pub async fn record_payer_success(pool, node_keys: &Keys, order: &Order) -> Result<(), MostroError> { + let mut tx = pool.begin().await.map_err(db_err)?; + // Idempotency token: the declaration row can be consumed exactly once. + let Some(decl) = db::take_declaration(&mut *tx, order.id).await? else { + return tx.commit().await.map_err(db_err); // nothing to do / already done + }; + if order.buyer_dispute || order.seller_dispute { // D-6 + return tx.commit().await.map_err(db_err); // declaration discarded + } + let (buyer_idkey, _) = order.is_full_privacy_order()?; + let Some(user) = buyer_idkey else { + return tx.commit().await.map_err(db_err); // D-4: nothing stored + }; + let seller_master = order.get_master_seller_pubkey() + .map(|k| k.to_string()).unwrap_or_else(|_| order.seller_pubkey.clone().unwrap_or_default()); + let cp = counterparty_id(node_keys, &seller_master); // D-7 + db::bump_history(&mut *tx, &user, &decl.payment_hash, &decl.payment_method, &cp, now()).await?; + tx.commit().await.map_err(db_err) +} + +pub fn counterparty_id(node_keys: &Keys, seller_master_pubkey: &str) -> String { + use bitcoin::hashes::{sha256, Hash, HashEngine}; // already a dependency (src/util.rs:3086) + let mut eng = sha256::Hash::engine(); + eng.input(b"mostro-payer-history-cp-v1"); + eng.input(node_keys.secret_key().as_secret_bytes()); + eng.input(seller_master_pubkey.as_bytes()); + sha256::Hash::from_engine(eng).to_string() // lowercase hex +} +``` + +Why this is safe +- `payment_success` may be reached twice (watcher + reconciler, §3.3). The + CAS guarantees only one caller enters the `rows_affected()==1` branch, and + `take_declaration` (DELETE … RETURNING) guarantees at most one increment + even if that invariant were ever broken. +- It runs *after* the order is terminal, so a failure here never blocks a + payout or a notification — it is logged and the declaration is simply lost + (the buyer loses one history point; nothing worse). +- It does not touch `orders`, respecting the "no full-row writes after + success" rule documented at `release.rs:1027-1029`. +- `CompletedByAdmin` / admin settle reach `Success` through the same + `do_payment → payment_success` path and are covered; the dispute flags + (D-6) decide whether they count. + +### 10.6 Pruning (`src/scheduler.rs`) + +A mode-agnostic job, `job_prune_payer_declarations`, every +`expiration`-class interval (reuse the 60 s cadence of +`job_process_dev_fee_payment`), running: + +```sql +DELETE FROM order_payer_declarations + WHERE order_id IN (SELECT id FROM orders WHERE status IN ()) +``` + +`TERMINAL_ORDER_STATUSES` already exists at `src/db.rs:27`. Success rows are +consumed in §10.5 before this job can see them, so the job only ever removes +declarations from cancelled / expired / admin-cancelled orders. Range-order +children are separate orders with separate declarations; nothing special. + +### 10.7 Cashu dispatch + +`dispatch_cashu` (`src/app.rs:599`) currently rejects every action outside its +allow-list. `DeclarePayer` and `PaymentHistory` are **not** added there in the +MVP (the Cashu release path does not exist yet); see §13. + +--- + +## 11. Authorization and privacy analysis + +### 11.1 Who can learn what + +| Party | Learns | Never learns | +|---|---|---| +| Seller (via Mostro) | buyer's committed `payment_hash` for **this order**; aggregate counters for **this** (buyer, hash) pair; whether the buyer is in full-privacy mode (already visible today through `Peer.reputation == None`) | buyer identity key, other trade keys, other order ids, which sellers were the counterparties, any hash other than the one the buyer chose to commit to this order | +| Buyer | nothing new about the seller | — | +| Mostro node | `(master_buyer_pubkey, payment_hash)` association + counters; keyed counterparty hashes | plaintext payer details (D-2) | +| Public relays | nothing | everything in this feature | + +### 11.2 Why there is no oracle + +- The only query is `payment-history` and it takes **no parameters** beyond + the order id. The `(user, hash)` pair is resolved server-side from the + order. A seller cannot ask about a hash the buyer did not commit to this + order, nor about a user who is not their counterparty in this order. +- Repeating the query returns the same numbers; it leaks nothing further. +- The seller already possesses the plaintext (the buyer sent it); learning + its hash is not new information. +- The seller cannot distinguish "buyer B used account X before" from "some + user used account X before" — they only see counters for the buyer they are + *currently* trading with, which is exactly gist §29. +- There is no `same_user(a, b)` primitive and none is introduced (gist §12). +- Counterparty identities are keyed hashes (D-7); even an exported DB does not + list which sellers a buyer dealt with without the node secret. + +### 11.3 Residual risks (accepted for the MVP) + +| Risk | Mitigation / status | +|---|---| +| Node DB compromise reveals `(identity key, payment_hash)` pairs. Hashes are brute-forceable by anyone who already knows the candidate account. | Same trust boundary as `master_*_pubkey` and `buyer_invoice` today. History is inherently a node-side function; a node that wants less retention can purge (§18). | +| Buyer colludes with sellers to farm history. | Needs real successful trades with real sats, distinct counterparties and elapsed time (gist §32); tiers in §12 weight all three. | +| Victim happens to be a Mostro user with history on the same account. | Only a problem under hash-only history (§18); D-1 keys on the *buyer's* identity, so the victim's history is not attributed to the attacker. | +| Buyer declares a hash but sends different plaintext to the seller. | Seller's client recomputes and flags mismatch (D-3); treated like a sender mismatch — recommend dispute. | +| Buyer in full-privacy mode gets no history forever. | Deliberate (D-4). Sellers who care can prefer reputation-mode buyers; clients must word it as "unavailable", not "new". | + +--- + +## 12. Client contract and UX guidance + +Normative for clients that opt in (checked via the info-event tags, §8.3). + +**Buyer side** +1. When the order is taken and `payer_history_enabled` is true, show a + "payment sender" form for the method in use, explaining why (gist §21). +2. Canonicalise, hash (§7), send `declare-payer`. Keep the plaintext locally. +3. Send the plaintext to the seller over the peer channel (chat or DM). +4. Before `fiat-sent`, confirm: *"Did you send the payment from the account + declared for this trade?"* +5. If `fiat-sent` answers `payer_not_declared`, go back to step 1. + +**Seller side** +1. On `payer-declared`, store the hash for the order. On receiving the + plaintext from the buyer, recompute; if it differs, show a hard warning. +2. On `payment-history` (push or reply), render two independent blocks: + *Sender match* (manual confirmation) and *Payment-account history*. +3. Never auto-release; never auto-refuse. The release screen shows both blocks + above `[Release]` / `[Dispute]` (gist §22). +4. If no `payment-history` arrived by the time fiat is reported sent and the + node has the feature enabled, show *"Buyer did not declare a payment + sender"* as its own warning. + +**Suggested tiers** (client policy, not protocol): + +| Tier | Condition (all of) | Wording | +|---|---|---| +| 🟢 Established | `successful_trades ≥ 5`, `distinct_counterparties ≥ 3`, `first_success_at` ≥ 30 days ago | "Established payment account" | +| 🟡 Limited | `successful_trades ≥ 1` and not Established | "Limited payment history" | +| 🔴 New | `successful_trades == 0` (`buyer_mode == reputation`) | "No previous successful trades with this account" | +| ⚪ Unavailable | `buyer_mode == full_privacy` | "History unavailable (buyer trades in full-privacy mode)" | + +Forbidden wording: "verified owner", "verified account", "trusted account" +(gist §33). + +--- + +## 13. Cashu mode — cross-track obligation + +The Cashu release track (`docs/cashu/03-track-b-release.md §5C`) makes a +scheduler watcher the **only** path to `Success`. When that lands it MUST call +`payer::success::record_payer_success` on its own `SettledHoldInvoice → Success` +CAS success branch, and `dispatch_cashu` MUST add `DeclarePayer | PaymentHistory` +to its routing once `FiatSent` is routed there. Until then the feature is +Lightning-only; `Settings` validation SHOULD warn (not fail) at boot when both +`[cashu].enabled` and `[payer_history].enabled` are set. + +--- + +## 14. Test plan + +All tests are in-file `#[cfg(test)]` modules using the existing scaffolding +(`create_test_pool`, `TestContextBuilder`, `queued_actions_for(order_id)` — +`src/app/fiat_sent.rs:95-176`). Coverage target for the new module: ≥ 80 %. + +**`mostro-core`** +- Round-trip serde for the three actions / two payloads / two reasons; exact + wire strings (`declare-payer`, `payer_declaration`, `invalid_payment_hash`). +- `MessageKind::verify()` accepts / rejects the matrix in §6.4. + +**`declare_payer_action`** +- feature off → `invalid_action`; unknown order → `not_found`. +- seller sends it → `invalid_pubkey`; non-party → `invalid_pubkey`. +- each status in §5.1 (allowed vs `not_allowed_by_status`). +- bad hash (wrong length, uppercase, non-hex) → `invalid_payment_hash`; + empty / 65-byte method → `invalid_payment_hash`; wrong payload → `invalid_payload`. +- happy path: row upserted, `PayerDeclared` queued to buyer (with request_id) + and seller (without); re-declaration overwrites and re-notifies. +- maker-buyer order with no seller yet: only the buyer ack is queued. + +**`fiat_sent_action` integration** +- feature off: byte-identical behaviour (existing tests unchanged). +- feature on, no declaration, `require_declaration=false`: no push, order + moves to `FiatSent`. +- `require_declaration=true`, no declaration → `payer_not_declared`, status + unchanged. +- with declaration: `FiatSentOk`×2 **and** one `PaymentHistory` to the seller + with zero counters; with seeded history rows the counters are returned. +- full-privacy buyer: push has `buyer_mode = full_privacy`, zero counters. + +**`payment_history_action`** +- buyer queries → `invalid_peer`; wrong statuses → `not_allowed_by_status`; + no declaration → `not_found`; happy path echoes `request_id`. + +**`record_payer_success`** +- increments `successful_trades`, sets first/last, inserts counterparty; + second call is a no-op (row consumed). +- same buyer+hash, two different sellers → `distinct_counterparties == 2`; + same seller twice → `1`. +- `buyer_dispute = 1` → declaration consumed, no history row. +- full-privacy buyer → declaration consumed, no history row. +- `counterparty_id` is deterministic for a key and differs across keys. + +**`payment_success` wiring** (extend `src/app/release.rs` tests with the +`PayoutStatusLookup` stub pattern, `release.rs:1074-1101`) +- history recorded on the CAS-success branch only; the "already finalised" + branch records nothing. + +**Scheduler** +- prune deletes declarations of cancelled/expired orders and leaves active + ones. + +**Info event** +- tags present/absent per config (`src/nip33.rs` tests next to + `bond_policy_tags`). + +--- + +## 15. PR breakdown (atomic, backwards-compatible) + +| ID | Repo | Scope | Depends on | +|---|---|---|---| +| **PH-0** | `mostro-core` | §6: actions, payloads, reasons, `verify()` arms, serde tests. Release. | — | +| **PH-1** | `mostrod` | Bump `mostro-core`; config section + helpers (§8.1–8.2); migration + `src/app/payer/db.rs` with unit tests (§9, §10.1). Nothing wired. | PH-0 | +| **PH-2** | `mostrod` | `declare_payer_action` + dispatch arm + tests (§10.2). | PH-1 | +| **PH-3** | `mostrod` | `fiat_sent` gate + push, `payment_history_action` + dispatch arm, `build_for_order` (§10.3–10.4). | PH-2 | +| **PH-4** | `mostrod` | `record_payer_success` + `payment_success` wiring + prune job (§10.5–10.6). | PH-1 (parallel with PH-2/3) | +| **PH-5** | `mostrod` | Info-event tags (§8.3), `docs/README.md` link, `ORDERS_AND_ACTIONS.md` row, Cashu boot warning (§13). | PH-1 | +| **PH-6** | `protocol` | New chapter `payer_declaration.md` (§6.5 examples, §7 canonicalisation registry with AR/EU/BR/… entries), `SUMMARY.md`, `message_suggestions_for_actions.md` reasons, `other_events.md` info tags. | PH-0 | + +Critical path: PH-0 → PH-1 → PH-2 → PH-3. PH-4, PH-5 and PH-6 run in parallel +after PH-1 / PH-0. Each `mostrod` PR must keep the existing suite green +**unmodified** (tests may be added, not changed). + +--- + +## 16. Definition of Done + +- [ ] With `[payer_history]` absent, `cargo test` passes with no test edited, + and `declare-payer` / `payment-history` answer `invalid_action`. +- [ ] With the feature on, the §5 flow works end-to-end against a test node: + buyer declares, seller receives `payer-declared` and a `payment-history` + push at `fiat-sent`, a second successful trade from the same account + shows `successful_trades = 1`, `distinct_counterparties = 1`. +- [ ] A full-privacy buyer yields `buyer_mode = full_privacy` and writes no + history rows (DB asserted). +- [ ] No new field appears on kinds 38383 / 38384; 38385 gains only the two + tags in §8.3 (`nip33` tests). +- [ ] `grep -rn "payment_hash" src/app/payer` shows no `tracing::` call that + logs a hash at `info` or above. +- [ ] `cargo clippy --all-targets --all-features` clean; `cargo fmt` clean. +- [ ] Protocol book chapter merged and linked from `docs/README.md`. + +--- + +## 17. Open questions (decide in review, not in code) + +1. **D-6 scope.** Should a dispute resolved *in the buyer's favour* by a solver + count as success? Current answer: no (conservative). Alternative: count it + but not its counterparty. +2. **Post-success query.** §10.4 returns `not_found` after `Success`. If + client authors need the "as seen at release time" snapshot for restored + sessions, keep the declaration row until prune instead of consuming it, + and use `payer_history.last_success_at < order.taken_at` — more state, more + edge cases. Default: keep the simple rule. +3. **`require_declaration` granularity.** Global only in the MVP. Per-order + (a maker flag on the 38383 event) would let sellers opt in individually + but needs a new public tag; defer. +4. **`payment_method` label.** Informational only. Should Mostro reject a + label that is not one of the order's `pm` entries (case-insensitive)? Cheap + consistency check vs. yet another way to get a `cant-do` for a free-form + string. Default: do not validate. + +--- + +## 18. Future extensions (explicitly out of scope) + +- **Hash-only history for full-privacy buyers** (`any user × hash`). Gives + full-privacy buyers *some* signal but attributes the victim's own Mostro + history to an attacker who targets a Mostro user; needs a separate risk + analysis and a distinct `buyer_mode` value. +- **Admin RPC** to purge history for a `user_pubkey` / a `payment_hash` + (retention policy, GDPR-style requests). +- **Economic weighting** (sats volume, age decay) and rotation warnings + ("this buyer has used N distinct accounts in the last 30 days"). +- **Cross-instance attestations** — must not introduce a global reusable + identifier (gist §37). +- **Method-specific canonicalisation registry** as versioned data in the + protocol repo so clients can update rules without a release. +- **Seller-side declaration** (symmetric protection for buyers against + "pay to a third-party account" scams) — same tables, role-swapped. + +--- + +## 19. Summary + +The mechanism combines **sender verification** (done by the seller, assisted by +a hash forwarded through Mostro) with **payment-identity continuity** (three +aggregate counters computed by Mostro from successful trades only). On this +codebase it maps to: three additive `mostro-core` variants, one migration with +three private tables, one new handler module, a ten-line hook on the single +`Success` CAS, a prune job, two info-event tags and one protocol chapter — +all behind a flag that is off by default and leaves the daemon byte-for-byte +unchanged when disabled. A triangulation attacker can still make the sender +match; they cannot cheaply make `successful_trades = 47, +distinct_counterparties = 29, first_success_at = nine months ago`. diff --git a/docs/README.md b/docs/README.md index d72b088c..1b6c7f48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Quick links to architecture and feature guides. - Orders & Actions: ORDERS_AND_ACTIONS.md - Admin RPC & Disputes: ADMIN_RPC_AND_DISPUTES.md - Anti-Abuse Bond: ANTI_ABUSE_BOND.md (opt-in maker/taker Lightning bond; off by default) +- Payment-Account History / Anti-Triangulation: PAYER_HISTORY_ANTI_TRIANGULATION.md (opt-in buyer payer declaration + private success history; off by default) - RPC Interface Reference: RPC.md - NIP-01 Kind 0 Metadata: NIP01_KIND0_METADATA.md From e360aa6ede721a4bf5624ef38b7b1d51b0886d2e Mon Sep 17 00:00:00 2001 From: "mostro-app[bot]" <3690068+mostro-app[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:02:31 +0000 Subject: [PATCH 2/4] docs: address payer history review feedback --- docs/PAYER_HISTORY_ANTI_TRIANGULATION.md | 167 ++++++++++------------- 1 file changed, 74 insertions(+), 93 deletions(-) diff --git a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md index 2d69a015..cef10888 100644 --- a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md +++ b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md @@ -119,10 +119,10 @@ File references are to this repository unless marked `$CORE` | Fact | Where | |---|---| -| Every message carries a **trade key** (`event.sender`) and an **identity / master key** (`event.identity`). The master key is the only durable per-user handle the daemon has. | `$CORE/src/nip59.rs` (`UnwrappedMessage`), `src/app.rs:412-428` | -| `users.pubkey` (PRIMARY KEY) is the master key. A `users` row is created **only** when `identity != sender`. | `migrations/20231005195154_users.sql`, `src/app.rs:186-198` | -| `orders.master_buyer_pubkey` / `master_seller_pubkey` hold the identity keys; `buyer_pubkey` / `seller_pubkey` the trade keys. | `migrations/20221222153301_orders.sql`, `src/util.rs:630-646` | -| **Full Privacy Mode is structural, not a flag**: the client never sends the identity key, so `identity == sender` and `master_*_pubkey == *_pubkey`. No `users` row, no trade-index continuity, reputation reads as zero, rating a full-privacy counterpart is a silent no-op. | `$CORE/src/order.rs:186-195` (doc comment), `src/util.rs:228-254`, `src/app/rate_user.rs:109-125`, `$CORE/src/order.rs:496-514` (`is_full_privacy_order`) | +| Every message carries a **trade key** (`event.sender`) and an **identity / master key** (`event.identity`). The master key is the only durable per-user handle the daemon has. | `$CORE/src/nip59.rs` (`UnwrappedMessage`), `handle_message_action_no_ln` in `src/app.rs` | +| `users.pubkey` (PRIMARY KEY) is the master key. A `users` row is created **only** when `identity != sender`. | `migrations/20231005195154_users.sql`, `handle_message_action_no_ln` in `src/app.rs` | +| `orders.master_buyer_pubkey` / `master_seller_pubkey` hold the identity keys; `buyer_pubkey` / `seller_pubkey` the trade keys. | `migrations/20221222153301_orders.sql`, `Order` buyer/seller pubkey helpers in `src/util.rs` | +| **Full Privacy Mode is structural, not a flag**: the client never sends the identity key, so `identity == sender` and `master_*_pubkey == *_pubkey`. No `users` row, no trade-index continuity, reputation reads as zero, rating a full-privacy counterpart is a silent no-op. | `Order` identity-key documentation in `$CORE/src/order.rs` (doc comment), `Order::is_full_privacy_order` usage in `src/util.rs`, `rate_user_action` in `src/app/rate_user.rs`, `Order::is_full_privacy_order` in `$CORE/src/order.rs` (`is_full_privacy_order`) | **Consequence:** in Full Privacy Mode Mostro has *no* cross-trade continuity for the buyer at all. The gist's "how Mostro identifies continuity between a @@ -133,25 +133,25 @@ user's trade keys is an implementation detail" resolves, on this codebase, to: - The only counterparty data Mostro forwards today is a pubkey plus an optional reputation snapshot, via `Payload::Peer(Peer { pubkey, reputation })` - (`src/app/fiat_sent.rs:44-74`, `src/util.rs:2241-2316`). + (`fiat_sent_action` in `src/app/fiat_sent.rs`, `enqueue_order_msg` peer payload handling in `src/util.rs`). - There is **no** buyer→seller payment-info channel through Mostro. - `Action::SendDm` hits the `_` catch-all in `src/app.rs:263` and is ignored; + `Action::SendDm` hits the `_` catch-all in `handle_message_action_no_ln` in `src/app.rs` and is ignored; peer chat is a pure client feature (`$CORE/src/chat/`, protocol `chat.md`). - `orders.payment_method` is an unvalidated free-form string, public on the - kind-38383 event as the multi-value `pm` tag (`src/nip33.rs:490-502`). + kind-38383 event as the multi-value `pm` tag (`info_to_tags` / order tag generation in `src/nip33.rs`). - `Action::FiatSent` carries no payload except the optional `NextTrade` - (`src/app/fiat_sent.rs:8-93`). + (`fiat_sent_action` in `src/app/fiat_sent.rs`). ### 3.3 Where "trade completed successfully" is known There is **exactly one** writer of `Status::Success` for a normal trade: -`payment_success` in `src/app/release.rs:1010-1070`. It performs a CAS +`payment_success` in `src/app/release.rs`. It performs a CAS `UPDATE orders SET status=?, event_id=? WHERE id=? AND status='settled-hold-invoice'` and treats `rows_affected()==0` as "another task already finalised this order — do not repeat side effects". It is reached from the in-process payout watcher -(`release.rs:830`), from crash-recovery reconciliation (`release.rs:1162`) and, -via `do_payment`, from admin settle (`src/app/admin_settle.rs:243`) and the -retry job (`src/scheduler.rs:236`). +(`release` watcher in `src/app/release.rs`), from crash-recovery reconciliation (`payment_success` reconciliation in `src/app/release.rs`) and, +via `do_payment`, from admin settle (`admin_settle_action` in `src/app/admin_settle.rs`) and the +retry job (`scheduler` retry job in `src/scheduler.rs`). The Cashu track defines a *different* success path — the release watcher in `docs/cashu/03-track-b-release.md §5C` — which is not implemented yet. See §13. @@ -160,20 +160,20 @@ The Cashu track defines a *different* success path — the release watcher in Kind 38383 (order) carries no pubkeys; kind 38384 (rating) is keyed by the rated user's pubkey and carries only rating aggregates; kind 38385 (info) carries -node policy tags (e.g. `bond_policy_tags`, `src/nip33.rs:678`). Nothing about +node policy tags (e.g. `bond_policy_tags`, `bond_policy_tags` / info tags in `src/nip33.rs`). Nothing about fiat accounts exists on any event and this feature must keep it that way. ### 3.5 Existing patterns we reuse | Pattern | Example | |---|---| -| Party authorisation is open-coded per handler against `event.sender` | `src/app/fiat_sent.rs:25-27` (`InvalidPubkey`), `src/app/release.rs:237-239` (`InvalidPeer`) | -| Post-success, order-scoped, once-only authorisation | `src/app/rate_user.rs:69-206` + `claim_order_rating_flag` CAS (`src/db.rs:1446-1473`) | -| Opt-in feature section with `Option<…Settings>` and `#[serde(default)]` | `AntiAbuseBondSettings` (`src/config/types.rs:40`), `Settings.anti_abuse_bond` (`src/config/settings.rs:31`) | +| Party authorisation is open-coded per handler against `event.sender` | `fiat_sent_action` buyer check in `src/app/fiat_sent.rs` (`InvalidPubkey`), `release_action` seller check in `src/app/release.rs` (`InvalidPeer`) | +| Post-success, order-scoped, once-only authorisation | `rate_user_action` in `src/app/rate_user.rs` + `claim_order_rating_flag` CAS (`claim_order_rating_flag` in `src/db.rs`) | +| Opt-in feature section with `Option<…Settings>` and `#[serde(default)]` | `AntiAbuseBondSettings` (`AntiAbuseBondSettings` in `src/config/types.rs`), `Settings.anti_abuse_bond` (`Settings.anti_abuse_bond` in `src/config/settings.rs`) | | Feature-owned module with its own `db.rs` | `src/app/bond/{mod,db,model,flow,…}.rs` | | Migration with a long `--` design header and per-column comments | `migrations/20260423120000_anti_abuse_bond.sql` | -| Node policy advertised on the info event | `bond_policy_tags` (`src/nip33.rs:678`) | -| Handler tests with in-memory sqlite + `TestContextBuilder` + `queued_actions_for(order_id)` | `src/app/fiat_sent.rs:95-311` | +| Node policy advertised on the info event | `bond_policy_tags` (`bond_policy_tags` / info tags in `src/nip33.rs`) | +| Handler tests with in-memory sqlite + `TestContextBuilder` + `queued_actions_for(order_id)` | `fiat_sent_action` tests in `src/app/fiat_sent.rs` | --- @@ -233,14 +233,13 @@ code path. **D-9 · Additive protocol surface only.** New `Action` variants, new `Payload` variants, new `CantDoReason` variants. `SmallOrder` is **not** touched — it is -`#[serde(deny_unknown_fields)]` and positional (`$CORE/src/order.rs:560-620`), +`#[serde(deny_unknown_fields)]` and positional (`SmallOrder` in `$CORE/src/order.rs`), so any new field would break every existing client. `FiatSentOk` keeps its `Payload::Peer` unchanged. **D-10 · Off by default, inert when off.** With `[payer_history]` absent or `enabled = false`: the new actions answer `cant-do invalid_action`, `fiat-sent` -is untouched, no table is written, no info tag is emitted beyond -`payer_history_enabled = false`. +is untouched, no table is written, and no payer-history info tags are emitted. **D-11 · Mostro never blocks on history.** The only enforcement knob is `require_declaration` (default `false`), which rejects `fiat-sent` when no @@ -259,7 +258,7 @@ as an identifier elsewhere). Clients must agree on this exact prefix (§7). Sell-order flow (buyer is the taker; the maker-buyer flow is symmetric — what matters is only *which side is the buyer*): -``` +```text Buyer (B) mostrod (M) Seller (S) | | | | take-sell / add-invoice ... | ... hold invoice paid ... | @@ -294,7 +293,7 @@ matters is only *which side is the buyer*): | `declare-payer` | buyer trade key | `WaitingPayment`, `WaitingBuyerInvoice`, `Active` | upsert declaration (last write wins) | | `declare-payer` | buyer | `FiatSent` or later | `cant-do not_allowed_by_status` (frozen) | | `payment-history` (push) | Mostro → seller | emitted inside `fiat_sent_action` after `fiat-sent-ok` | — | -| `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice`, `Success` | same object as push | +| `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice`; `Success` returns `not_found` after the declaration is consumed | same object as push before success | | success hook | daemon | `SettledHoldInvoice → Success` CAS | history += 1, declaration consumed | | prune | scheduler | order in a terminal non-success status | declaration deleted | @@ -311,7 +310,7 @@ These require a `mostro-core` **minor** release (`0.15.0` or `0.14.6` per the project's versioning habit); `mostrod` PRs then bump the dependency. All additions follow the existing serde conventions: `Action` is `kebab-case`, `Payload` and `CantDoReason` are `snake_case` -(`$CORE/src/message.rs:64`, `:682`, `$CORE/src/error.rs:26`). +(`Action`, `Payload` and `CantDoReason` in `$CORE/src/message.rs` / `$CORE/src/error.rs`). ### 6.1 `Action` (`$CORE/src/message.rs`) @@ -344,9 +343,6 @@ PaymentHistory(PaymentHistory), pub struct PayerDeclaration { /// sha256("mostro-payer-v1|" || canonical_payment_data), 64 lowercase hex. pub payment_hash: String, - /// Free-form label chosen by the client, ≤ 64 bytes (e.g. "SEPA", "CVU"). - /// Informational; Mostro does not validate it against the order's `pm`. - pub payment_method: String, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] @@ -377,7 +373,7 @@ Wire keys: `payer_declaration`, `payment_history`. ### 6.3 `CantDoReason` (`$CORE/src/error.rs`) ```rust -/// `payment_hash` is not 64 lowercase hex chars, or `payment_method` is empty / too long. +/// `payment_hash` is not 64 lowercase hex chars. InvalidPaymentHash, /// `fiat-sent` refused because the node requires a prior `declare-payer`. PayerNotDeclared, @@ -388,7 +384,7 @@ reused for everything else: `invalid_action` (feature off), `not_found` (order), `invalid_pubkey` (not the buyer), `invalid_peer` (not the seller), `not_allowed_by_status`, `invalid_payload`. -### 6.4 `MessageKind::verify()` matrix (`$CORE/src/message.rs:809-913`) +### 6.4 `MessageKind::verify()` matrix (`MessageKind::verify()` in `$CORE/src/message.rs`) The match is exhaustive on `Action`; add: @@ -407,8 +403,7 @@ Buyer → Mostro: {"order": {"version": 2, "request_id": 981231, "trade_index": 7, "id": "4f1c…", "action": "declare-payer", "payload": {"payer_declaration": { - "payment_hash": "9b0e…c1", - "payment_method": "CVU"}}}}, + "payment_hash": "9b0e…c1"}}}}, "", ["", ""] ] @@ -421,8 +416,7 @@ Mostro → buyer (ack) and Mostro → seller (forward), identical body: {"order": {"version": 2, "request_id": 981231, "trade_index": null, "id": "4f1c…", "action": "payer-declared", "payload": {"payer_declaration": { - "payment_hash": "9b0e…c1", - "payment_method": "CVU"}}}}, + "payment_hash": "9b0e…c1"}}}}, null, null ] ``` @@ -488,7 +482,7 @@ Summary of the contract: Examples (canonical → hashed): -``` +```text AR|CVU|0000003100012345678901|27123456789 EU|SEPA|DE89370400440532013000|ALICE SMITH BR|PIX|+5511999998888 @@ -552,14 +546,14 @@ pub fn payer_declaration_required() -> bool // implies enabled ### 8.3 Info event (kind 38385) -`info_to_tags` (`src/nip33.rs:572`) appends `payer_history_tags(settings)`: +`info_to_tags` in `src/nip33.rs` appends `payer_history_tags(settings)`: | Tag | Value | |---|---| -| `payer_history_enabled` | `"true"` / `"false"` (always emitted) | +| `payer_history_enabled` | `"true"` (only when enabled) | | `payer_declaration_required` | `"true"` / `"false"` (only when enabled) | -Clients use these to decide whether to show the declaration screen at all. +When the feature is disabled or absent, `info_to_tags` emits no payer-history tags, preserving disabled-mode output byte-for-byte. Clients treat missing tags as disabled. --- @@ -575,7 +569,6 @@ style (long `--` header explaining *why*, per-column comments). Three tables; CREATE TABLE IF NOT EXISTS order_payer_declarations ( order_id char(36) PRIMARY KEY NOT NULL, -- orders.id (uuid) payment_hash char(64) NOT NULL, -- sha256 hex, lowercase - payment_method varchar(64) NOT NULL, -- client label, informational declared_at integer NOT NULL -- unix secs of last upsert ); @@ -584,7 +577,6 @@ CREATE TABLE IF NOT EXISTS order_payer_declarations ( CREATE TABLE IF NOT EXISTS payer_history ( user_pubkey char(64) NOT NULL, -- orders.master_buyer_pubkey (reputation mode only) payment_hash char(64) NOT NULL, - payment_method varchar(64) NOT NULL, -- label seen on the most recent success first_success_at integer NOT NULL, last_success_at integer NOT NULL, successful_trades integer NOT NULL DEFAULT 0, @@ -610,7 +602,7 @@ Notes - **Retention:** declarations are ephemeral (§10.5). History rows are kept indefinitely in the MVP; an admin RPC to purge a `user_pubkey` is listed in §18. -- The existing migration reconciler (`src/db.rs:161-260`) only special-cases +- The existing migration reconciler (`run_migrations` in `src/db.rs`) only special-cases `ADD COLUMN`; plain `CREATE TABLE IF NOT EXISTS` needs nothing extra. --- @@ -626,9 +618,9 @@ New module `src/app/payer/` (`mod.rs`, `db.rs`, `declare.rs`, `history.rs`, ```rust pub struct PayerDeclarationRow { pub order_id: Uuid, pub payment_hash: String, - pub payment_method: String, pub declared_at: i64 } + pub declared_at: i64 } -pub async fn upsert_declaration(pool, order_id: Uuid, hash: &str, method: &str, now: i64) +pub async fn upsert_declaration(pool, order_id: Uuid, hash: &str, now: i64) -> Result<(), MostroError>; // INSERT … ON CONFLICT(order_id) DO UPDATE pub async fn find_declaration(pool, order_id: Uuid) -> Result, MostroError>; @@ -636,7 +628,7 @@ pub async fn take_declaration<'e, E: sqlx::Executor<'e>>(exec: E, order_id: Uuid -> Result, MostroError>; // DELETE … RETURNING * (idempotency token) pub async fn load_history(pool, user_pubkey: &str, hash: &str) -> Result<(u32 /*trades*/, u32 /*cps*/, Option, Option), MostroError>; -pub async fn bump_history<'e, E>(exec: E, user_pubkey, hash, method, counterparty_id, now) +pub async fn bump_history<'e, E>(exec: E, user_pubkey, hash, counterparty_id, now) -> Result<(), MostroError>; // upsert payer_history + INSERT OR IGNORE counterparties pub async fn prune_declarations_for_terminal_orders(pool) -> Result; ``` @@ -652,7 +644,7 @@ pub fn validate_payment_hash(h: &str) -> Result<(), MostroError> { ### 10.2 `declare_payer_action` (`src/app/payer/declare.rs`) -Wired in `handle_message_action_no_ln` (`src/app.rs:207-268`) as +Wired in `handle_message_action_no_ln` (`handle_message_action_no_ln` in `src/app.rs`) as `Action::DeclarePayer => declare_payer_action(ctx, msg, event, my_keys)`. ```rust @@ -662,7 +654,7 @@ pub async fn declare_payer_action(ctx: &AppContext, msg: Message, return Err(MostroCantDo(CantDoReason::InvalidAction)); // D-10 } let order = get_order(&msg, ctx.pool()).await?; // not_found - if order.get_buyer_pubkey().ok() != Some(event.sender) { // same check as fiat_sent.rs:25 + if order.get_buyer_pubkey().ok() != Some(event.sender) { // same check as `fiat_sent_action` buyer check in `src/app/fiat_sent.rs` return Err(MostroCantDo(CantDoReason::InvalidPubkey)); } let status = order.get_order_status()?; @@ -674,11 +666,8 @@ pub async fn declare_payer_action(ctx: &AppContext, msg: Message, _ => return Err(MostroCantDo(CantDoReason::InvalidPayload)), }; validate_payment_hash(&decl.payment_hash)?; - if decl.payment_method.is_empty() || decl.payment_method.len() > 64 { - return Err(MostroCantDo(CantDoReason::InvalidPaymentHash)); - } db::upsert_declaration(ctx.pool(), order.id, &decl.payment_hash, - &decl.payment_method, Timestamp::now().as_u64() as i64).await?; + Timestamp::now().as_u64() as i64).await?; let request_id = msg.get_inner_message_kind().request_id; let payload = Some(Payload::PayerDeclaration(decl)); @@ -699,12 +688,11 @@ Design notes order whose taker has not paid — `get_seller_pubkey()` failing is not an error; the seller will get the hash with the `payment-history` push later. - No spam-gate change: the sender is already in the known-keys lane as a - party to a non-terminal order (`src/db.rs:40-80`). + party to a non-terminal order (`is_known_pubkey` in `src/db.rs`). ### 10.3 Push from `fiat_sent_action` and the optional gate -In `src/app/fiat_sent.rs`, after the `Active` status check and the buyer -check (`:19-27`) and **before** the status transition: +In `src/app/fiat_sent.rs`, after the `Active` status check and the buyer check and **before** the status transition: ```rust if Settings::payer_declaration_required() @@ -713,7 +701,7 @@ if Settings::payer_declaration_required() } ``` -After the two `FiatSentOk` enqueues (`:50-74`): +After `order_updated.update(pool).await?` succeeds and before returning: ```rust if Settings::is_payer_history_enabled() { @@ -725,9 +713,7 @@ if Settings::is_payer_history_enabled() { ``` `build_for_order` returns `None` when there is no declaration (no push, see -§6.5). Any DB error here is logged and **must not** fail `fiat_sent_action` -after the status has already moved — wrap in `if let Err(e) … tracing::warn!` -rather than `?` once the transition is committed. +§6.5). The push is only queued after the local `FiatSent` transition is durable. Any DB error while building history after that point is logged and **must not** fail `fiat_sent_action`; wrap it in `if let Err(e) … tracing::warn!` rather than `?` once the transition is committed. ### 10.4 `payment_history_action` — the seller query (`src/app/payer/history.rs`) @@ -754,8 +740,6 @@ pub async fn payment_history_action(ctx, msg, event, _my_keys) -> Result<(), Mos pub async fn build_for_order(pool, node_keys: &Keys, order: &Order) -> Result, MostroError> { let Some(decl) = db::find_declaration(pool, order.id).await? else { return Ok(None) }; - // After Success the declaration is gone; fall back to the history row only - // when the order is already terminal-success (restore-session use case). let (normal_buyer_idkey, _) = order.is_full_privacy_order()?; let Some(user) = normal_buyer_idkey else { return Ok(Some(PaymentHistory::unavailable(decl.payment_hash))); // D-4 @@ -778,42 +762,43 @@ question if client authors want otherwise.) ### 10.5 Success hook (`src/app/payer/success.rs`) — **D-5** -Called from `payment_success` (`src/app/release.rs:1010`) **only** on the -`rows_affected() == 1` branch, immediately after the CAS and before the -`PurchaseCompleted` enqueue: +Called from `payment_success` in `src/app/release.rs` **only** on the +`rows_affected() == 1` branch, inside the Success transaction before commit and +before the `PurchaseCompleted` enqueue: ```rust -// release.rs, after the CAS succeeded: +// release.rs, inside the same transaction that performs the Success CAS: if Settings::is_payer_history_enabled() { - if let Err(e) = payer::success::record_payer_success(pool, ctx.keys(), &order_updated).await { - tracing::warn!("payer history not recorded for order {}: {e}", order_updated.id); - } + payer::success::record_payer_success(&mut tx, ctx.keys(), &order_updated).await?; } ``` ```rust -pub async fn record_payer_success(pool, node_keys: &Keys, order: &Order) -> Result<(), MostroError> { - let mut tx = pool.begin().await.map_err(db_err)?; +pub async fn record_payer_success( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + node_keys: &Keys, + order: &Order, +) -> Result<(), MostroError> { // Idempotency token: the declaration row can be consumed exactly once. - let Some(decl) = db::take_declaration(&mut *tx, order.id).await? else { - return tx.commit().await.map_err(db_err); // nothing to do / already done + let Some(decl) = db::take_declaration(&mut **tx, order.id).await? else { + return Ok(()); // nothing to do / already done }; if order.buyer_dispute || order.seller_dispute { // D-6 - return tx.commit().await.map_err(db_err); // declaration discarded + return Ok(()); // declaration discarded } let (buyer_idkey, _) = order.is_full_privacy_order()?; let Some(user) = buyer_idkey else { - return tx.commit().await.map_err(db_err); // D-4: nothing stored + return Ok(()); // D-4: nothing stored }; let seller_master = order.get_master_seller_pubkey() .map(|k| k.to_string()).unwrap_or_else(|_| order.seller_pubkey.clone().unwrap_or_default()); let cp = counterparty_id(node_keys, &seller_master); // D-7 - db::bump_history(&mut *tx, &user, &decl.payment_hash, &decl.payment_method, &cp, now()).await?; - tx.commit().await.map_err(db_err) + db::bump_history(&mut **tx, &user, &decl.payment_hash, &cp, now()).await?; + Ok(()) } pub fn counterparty_id(node_keys: &Keys, seller_master_pubkey: &str) -> String { - use bitcoin::hashes::{sha256, Hash, HashEngine}; // already a dependency (src/util.rs:3086) + use bitcoin::hashes::{sha256, Hash, HashEngine}; // already a dependency (`src/util.rs`) let mut eng = sha256::Hash::engine(); eng.input(b"mostro-payer-history-cp-v1"); eng.input(node_keys.secret_key().as_secret_bytes()); @@ -826,12 +811,11 @@ Why this is safe - `payment_success` may be reached twice (watcher + reconciler, §3.3). The CAS guarantees only one caller enters the `rows_affected()==1` branch, and `take_declaration` (DELETE … RETURNING) guarantees at most one increment - even if that invariant were ever broken. -- It runs *after* the order is terminal, so a failure here never blocks a - payout or a notification — it is logged and the declaration is simply lost - (the buyer loses one history point; nothing worse). + even if that invariant were ever broken. Because the history write shares the + CAS transaction, a database failure rolls back the success transition and the + declaration remains retryable by the existing payment-success retry paths. - It does not touch `orders`, respecting the "no full-row writes after - success" rule documented at `release.rs:1027-1029`. + success" rule documented at `payment_success` in `src/app/release.rs`. - `CompletedByAdmin` / admin settle reach `Success` through the same `do_payment → payment_success` path and are covered; the dispute flags (D-6) decide whether they count. @@ -847,14 +831,14 @@ DELETE FROM order_payer_declarations WHERE order_id IN (SELECT id FROM orders WHERE status IN ()) ``` -`TERMINAL_ORDER_STATUSES` already exists at `src/db.rs:27`. Success rows are +`TERMINAL_ORDER_STATUSES` already exists at `TERMINAL_ORDER_STATUSES` in `src/db.rs`. Success rows are consumed in §10.5 before this job can see them, so the job only ever removes declarations from cancelled / expired / admin-cancelled orders. Range-order children are separate orders with separate declarations; nothing special. ### 10.7 Cashu dispatch -`dispatch_cashu` (`src/app.rs:599`) currently rejects every action outside its +`dispatch_cashu` in `src/app.rs` currently rejects every action outside its allow-list. `DeclarePayer` and `PaymentHistory` are **not** added there in the MVP (the Cashu release path does not exist yet); see §13. @@ -953,7 +937,7 @@ Lightning-only; `Settings` validation SHOULD warn (not fail) at boot when both All tests are in-file `#[cfg(test)]` modules using the existing scaffolding (`create_test_pool`, `TestContextBuilder`, `queued_actions_for(order_id)` — -`src/app/fiat_sent.rs:95-176`). Coverage target for the new module: ≥ 80 %. +`fiat_sent_action` test scaffolding in `src/app/fiat_sent.rs`). Coverage target for the new module: ≥ 80 %. **`mostro-core`** - Round-trip serde for the three actions / two payloads / two reasons; exact @@ -965,7 +949,7 @@ All tests are in-file `#[cfg(test)]` modules using the existing scaffolding - seller sends it → `invalid_pubkey`; non-party → `invalid_pubkey`. - each status in §5.1 (allowed vs `not_allowed_by_status`). - bad hash (wrong length, uppercase, non-hex) → `invalid_payment_hash`; - empty / 65-byte method → `invalid_payment_hash`; wrong payload → `invalid_payload`. + wrong payload → `invalid_payload`. - happy path: row upserted, `PayerDeclared` queued to buyer (with request_id) and seller (without); re-declaration overwrites and re-notifies. - maker-buyer order with no seller yet: only the buyer ack is queued. @@ -994,16 +978,17 @@ All tests are in-file `#[cfg(test)]` modules using the existing scaffolding - `counterparty_id` is deterministic for a key and differs across keys. **`payment_success` wiring** (extend `src/app/release.rs` tests with the -`PayoutStatusLookup` stub pattern, `release.rs:1074-1101`) -- history recorded on the CAS-success branch only; the "already finalised" - branch records nothing. +`PayoutStatusLookup` stub pattern, `payment_success` tests in `src/app/release.rs`) +- history recorded in the same transaction as the CAS-success branch only; the + "already finalised" branch records nothing, and an injected history-write + failure leaves the order retryable rather than finalized without history. **Scheduler** - prune deletes declarations of cancelled/expired orders and leaves active ones. **Info event** -- tags present/absent per config (`src/nip33.rs` tests next to +- tags absent when disabled and present only when enabled (`src/nip33.rs` tests next to `bond_policy_tags`). --- @@ -1036,8 +1021,8 @@ after PH-1 / PH-0. Each `mostrod` PR must keep the existing suite green shows `successful_trades = 1`, `distinct_counterparties = 1`. - [ ] A full-privacy buyer yields `buyer_mode = full_privacy` and writes no history rows (DB asserted). -- [ ] No new field appears on kinds 38383 / 38384; 38385 gains only the two - tags in §8.3 (`nip33` tests). +- [ ] No new field appears on kinds 38383 / 38384; 38385 gains the §8.3 + tags only when `[payer_history].enabled = true` (`nip33` tests). - [ ] `grep -rn "payment_hash" src/app/payer` shows no `tracing::` call that logs a hash at `info` or above. - [ ] `cargo clippy --all-targets --all-features` clean; `cargo fmt` clean. @@ -1058,10 +1043,6 @@ after PH-1 / PH-0. Each `mostrod` PR must keep the existing suite green 3. **`require_declaration` granularity.** Global only in the MVP. Per-order (a maker flag on the 38383 event) would let sellers opt in individually but needs a new public tag; defer. -4. **`payment_method` label.** Informational only. Should Mostro reject a - label that is not one of the order's `pm` entries (case-insensitive)? Cheap - consistency check vs. yet another way to get a `cant-do` for a free-form - string. Default: do not validate. --- From 47f48d78aa6778e792a9f16fe0fcadcb207fb90c Mon Sep 17 00:00:00 2001 From: "mostro-app[bot]" <3690068+mostro-app[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:11:29 +0000 Subject: [PATCH 3/4] docs: finish payer history review fixes --- docs/PAYER_HISTORY_ANTI_TRIANGULATION.md | 41 +++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md index cef10888..2ec65022 100644 --- a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md +++ b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md @@ -160,7 +160,7 @@ The Cashu track defines a *different* success path — the release watcher in Kind 38383 (order) carries no pubkeys; kind 38384 (rating) is keyed by the rated user's pubkey and carries only rating aggregates; kind 38385 (info) carries -node policy tags (e.g. `bond_policy_tags`, `bond_policy_tags` / info tags in `src/nip33.rs`). Nothing about +node policy tags (e.g. `bond_policy_tags` from `src/nip33.rs`). Nothing about fiat accounts exists on any event and this feature must keep it that way. ### 3.5 Existing patterns we reuse @@ -293,7 +293,8 @@ matters is only *which side is the buyer*): | `declare-payer` | buyer trade key | `WaitingPayment`, `WaitingBuyerInvoice`, `Active` | upsert declaration (last write wins) | | `declare-payer` | buyer | `FiatSent` or later | `cant-do not_allowed_by_status` (frozen) | | `payment-history` (push) | Mostro → seller | emitted inside `fiat_sent_action` after `fiat-sent-ok` | — | -| `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice`; `Success` returns `not_found` after the declaration is consumed | same object as push before success | +| `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice` | same object as push before success | +| `payment-history` (query) | seller trade key | `Success` | `cant-do not_found` after the declaration is consumed | | success hook | daemon | `SettledHoldInvoice → Success` CAS | history += 1, declaration consumed | | prune | scheduler | order in a terminal non-success status | declaration deleted | @@ -409,7 +410,7 @@ Buyer → Mostro: ] ``` -Mostro → buyer (ack) and Mostro → seller (forward), identical body: +Mostro → buyer ack: ```json [ @@ -421,7 +422,17 @@ Mostro → buyer (ack) and Mostro → seller (forward), identical body: ] ``` -(The seller copy carries `request_id: null`.) +Mostro → seller forward (unsolicited, so `request_id` is null): + +```json +[ + {"order": {"version": 2, "request_id": null, "trade_index": null, + "id": "4f1c…", "action": "payer-declared", + "payload": {"payer_declaration": { + "payment_hash": "9b0e…c1"}}}}, + null, null +] +``` Mostro → seller, pushed immediately after `fiat-sent-ok`: @@ -705,15 +716,22 @@ After `order_updated.update(pool).await?` succeeds and before returning: ```rust if Settings::is_payer_history_enabled() { - if let Some(h) = payer::history::build_for_order(pool, ctx.keys(), &order_updated).await? { - enqueue_order_msg(None, Some(order_updated.id), Action::PaymentHistory, - Some(Payload::PaymentHistory(h)), seller_pubkey, None).await; + match payer::history::build_for_order(pool, ctx.keys(), &order_updated).await { + Ok(Some(h)) => { + enqueue_order_msg(None, Some(order_updated.id), Action::PaymentHistory, + Some(Payload::PaymentHistory(h)), seller_pubkey, None).await; + } + Ok(None) => {} + Err(e) => tracing::warn!("payer history push skipped for order {}: {e}", + order_updated.id), } } ``` `build_for_order` returns `None` when there is no declaration (no push, see -§6.5). The push is only queued after the local `FiatSent` transition is durable. Any DB error while building history after that point is logged and **must not** fail `fiat_sent_action`; wrap it in `if let Err(e) … tracing::warn!` rather than `?` once the transition is committed. +§6.5). The push is only queued after the local `FiatSent` transition is durable; +a history lookup failure after that point is logged and **must not** fail +`fiat_sent_action`. ### 10.4 `payment_history_action` — the seller query (`src/app/payer/history.rs`) @@ -726,9 +744,10 @@ pub async fn payment_history_action(ctx, msg, event, _my_keys) -> Result<(), Mos if order.get_seller_pubkey().ok() != Some(event.sender) { // seller only, §11 return Err(MostroCantDo(CantDoReason::InvalidPeer)); } - if !matches!(order.get_order_status()?, - Status::FiatSent | Status::Dispute | Status::SettledHoldInvoice | Status::Success) { - return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)); + match order.get_order_status()? { + Status::FiatSent | Status::Dispute | Status::SettledHoldInvoice => {} + Status::Success => return Err(MostroCantDo(CantDoReason::NotFound)), + _ => return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)), } let history = build_for_order(ctx.pool(), ctx.keys(), &order).await? .ok_or(MostroCantDo(CantDoReason::NotFound))?; // buyer never declared From 2130e7face4f486a4d1d267b4526b064b34ac9ee Mon Sep 17 00:00:00 2001 From: grunch Date: Sun, 23 Aug 2026 14:42:40 -0300 Subject: [PATCH 4/4] docs: harden D-7 with experienced counterparties Add an experienced_counterparties metric alongside distinct_counterparties: a counterparty qualifies when, at success time, it already had >= N successful undisputed trades with OTHER buyers spanning >= D days (current trade and same-buyer trades never count). The flag is stored per (buyer, payment_hash, counterparty_id) and is monotone 0->1. N and D are node policy ([payer_history] experienced_min_trades / experienced_min_days, defaults 5/30), advertised on the info event. Established tier now requires experienced_counterparties >= 1. --- docs/PAYER_HISTORY_ANTI_TRIANGULATION.md | 273 +++++++++++++++++++---- 1 file changed, 229 insertions(+), 44 deletions(-) diff --git a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md index 2ec65022..3eaa2dde 100644 --- a/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md +++ b/docs/PAYER_HISTORY_ANTI_TRIANGULATION.md @@ -73,8 +73,9 @@ The seller's client ends up showing two separate checks before release: 1. **Sender match** — does the fiat sender shown by the seller's bank match the details the buyer declared? (Client-side, human-verified.) 2. **Payment-account history** — how much successful history does *this - buyer* have with *this payment identity*? (Computed privately by - `mostrod`, returned as three aggregate numbers.) + buyer* have with *this payment identity*, and how experienced were the + counterparties behind it? (Computed privately by `mostrod`, returned as + four aggregate counters.) A sophisticated attacker can satisfy (1). They cannot cheaply satisfy (2). @@ -85,7 +86,11 @@ A sophisticated attacker can satisfy (1). They cannot cheaply satisfy (2). ### Goals - Give the seller an aggregate, order-scoped history signal for the buyer's declared payment identity: `successful_trades`, `distinct_counterparties`, - `first_success_at`, `last_success_at`. + `experienced_counterparties`, `first_success_at`, `last_success_at`. +- Harden the counterparty signal against Sybil farming: separately count the + counterparties that were already *experienced* on this node when they traded + with the buyer, under node-configurable thresholds (D-7) — a flat one-hop + signal, deliberately not recursive reputation. - Let the seller's client verify that the payment details it received from the buyer are the same ones the buyer committed to Mostro (hash consistency). - Build history **only** from trades that reach `Status::Success`. @@ -217,13 +222,56 @@ solver is not clean evidence of a healthy buyer↔account relationship, and this removes the cheapest "farm history while disputing" path. Revisit with data (§17). -**D-7 · Counterparties are stored as a keyed hash, not as pubkeys.** +**D-7 · Counterparties are stored as a keyed hash, not as pubkeys, and carry +a monotone "experienced" qualification snapshot.** `counterparty_id = sha256("mostro-payer-history-cp-v1" ‖ node_secret ‖ master_seller_pubkey)` where `node_secret` is the node's Nostr secret key bytes. The history tables alone cannot be joined back to `users`/`orders` without the node key, and a full-privacy seller (whose master key is a fresh trade key) simply counts as a new counterparty each time — which is the conservative direction. +**Experienced counterparty.** A distinct counterparty counts as *experienced* +for a `(buyer, payment_hash)` pair when, **at the moment a trade with that +pair reaches `Success`**, the seller already had — counting only history that +existed **before** that trade: + +- at least `experienced_min_trades` (`N`) successful, undisputed Mostro + trades; and +- at least `experienced_min_days` (`D`) operating days since its first such + trade. + +Qualification is computed from the `orders` table (§10.1) — the seller's whole +node-side record, including trades that predate this feature — not from the +history tables, so sellers never need the feature enabled to qualify. The +trade being recorded **never** counts toward its own counterparty's +qualification, and — an explicit, documented anti-Sybil choice — trades the +seller previously made **with the same buyer identity** do not count either: +without that exclusion a two-key Sybil (a seller bot that only ever trades +with the attacker's own buyer) qualifies at zero external cost; with it, the +fake seller must build real history with third parties. Because qualification +is therefore buyer-relative, the flag is stored on the +`(user_pubkey, payment_hash, counterparty_id)` row (§9), which is buyer-scoped +by construction. Full-privacy sellers never qualify: their master key is a +fresh trade key, so their qualifying history is always zero — the same +conservative direction as above. + +The flag is **monotone** and is evaluated only when a success is recorded. A +counterparty stored with `experienced = 0` is upgraded to `1` only when a +*subsequent* successful trade with the same `(buyer, payment_hash)` pair +happens after the seller has crossed the thresholds; it is never flipped +retroactively merely because the seller's record improved later, and it never +flips back. + +`N` and `D` are **node policy, not protocol constants**: operators set them in +`[payer_history]` (§8.1, defaults 5 and 30) and they are advertised on the +info event (§8.3) so clients can explain the metric without hard-coding +thresholds. + +This is deliberately a flat, one-hop signal. It is **not** recursive +reputation: a counterparty's own `experienced_counterparties` value plays no +part in its qualification, and no graph scoring, PageRank or Web-of-Trust +weighting is applied. + **D-8 · Push, then pull.** Mostro *pushes* the history to the seller right after `fiat-sent-ok`. A seller→Mostro *query* action exists for session restore and clients that missed the push; it returns the same object and is @@ -295,7 +343,7 @@ matters is only *which side is the buyer*): | `payment-history` (push) | Mostro → seller | emitted inside `fiat_sent_action` after `fiat-sent-ok` | — | | `payment-history` (query) | seller trade key | `FiatSent`, `Dispute`, `SettledHoldInvoice` | same object as push before success | | `payment-history` (query) | seller trade key | `Success` | `cant-do not_found` after the declaration is consumed | -| success hook | daemon | `SettledHoldInvoice → Success` CAS | history += 1, declaration consumed | +| success hook | daemon | `SettledHoldInvoice → Success` CAS | history += 1, counterparty qualification snapshot (D-7), declaration consumed | | prune | scheduler | order in a terminal non-success status | declaration deleted | `Active` is the normal declaration window. The two `Waiting*` statuses are @@ -358,6 +406,11 @@ pub struct PaymentHistory { pub successful_trades: u32, /// Number of distinct sellers among those orders (keyed hash, see D-7). pub distinct_counterparties: u32, + /// How many of those distinct counterparties were already *experienced* + /// (D-7) at the time of their successful trade with this + /// (buyer, payment_hash) pair. Thresholds are node policy, advertised + /// on the info event (§8.3). + pub experienced_counterparties: u32, /// Unix seconds of the first / last successful trade; `None` when /// `successful_trades == 0`. pub first_success_at: Option, @@ -445,6 +498,7 @@ Mostro → seller, pushed immediately after `fiat-sent-ok`: "buyer_mode": "reputation", "successful_trades": 47, "distinct_counterparties": 29, + "experienced_counterparties": 11, "first_success_at": 1762128000, "last_success_at": 1787654321}}}}, null, null @@ -460,6 +514,7 @@ Full-privacy buyer: ```json {"payment_history": {"payment_hash": "9b0e…c1", "buyer_mode": "full_privacy", "successful_trades": 0, "distinct_counterparties": 0, + "experienced_counterparties": 0, "first_success_at": null, "last_success_at": null}} ``` @@ -528,6 +583,13 @@ seller that sender verification is unavailable (gist §34). # # buyer sent `declare-payer` first. Leave false unless your market relies # # on sender-verifiable rails only. # require_declaration = false +# # "Experienced counterparty" thresholds (D-7): a seller qualifies for a +# # buyer's history when, at the moment a trade with that buyer succeeds, it +# # already had >= experienced_min_trades successful, undisputed trades with +# # OTHER buyers ... +# experienced_min_trades = 5 +# # ... and its first such trade was >= experienced_min_days days ago. +# experienced_min_days = 30 ``` ### 8.2 `src/config/types.rs` @@ -543,16 +605,26 @@ pub struct PayerHistorySettings { /// Reject `fiat-sent` when no `declare-payer` was received for the order. #[serde(default)] pub require_declaration: bool, + /// "Experienced counterparty" thresholds (D-7). Node policy, advertised + /// on the info event; NOT part of the protocol. + #[serde(default = "default_experienced_min_trades")] + pub experienced_min_trades: u32, + #[serde(default = "default_experienced_min_days")] + pub experienced_min_days: u32, } + +const fn default_experienced_min_trades() -> u32 { 5 } +const fn default_experienced_min_days() -> u32 { 30 } ``` `Settings` gains `#[serde(default)] pub payer_history: Option` -(`src/config/settings.rs`, next to `anti_abuse_bond`) and two helpers mirroring +(`src/config/settings.rs`, next to `anti_abuse_bond`) and three helpers mirroring `is_cashu_enabled()`: ```rust pub fn is_payer_history_enabled() -> bool pub fn payer_declaration_required() -> bool // implies enabled +pub fn payer_history_experience_thresholds() -> (u32, u32) // (min_trades, min_days), defaults 5/30 ``` ### 8.3 Info event (kind 38385) @@ -563,8 +635,10 @@ pub fn payer_declaration_required() -> bool // implies enabled |---|---| | `payer_history_enabled` | `"true"` (only when enabled) | | `payer_declaration_required` | `"true"` / `"false"` (only when enabled) | +| `payer_history_experienced_min_trades` | decimal string, e.g. `"5"` (only when enabled) | +| `payer_history_experienced_min_days` | decimal string, e.g. `"30"` (only when enabled) | -When the feature is disabled or absent, `info_to_tags` emits no payer-history tags, preserving disabled-mode output byte-for-byte. Clients treat missing tags as disabled. +When the feature is disabled or absent, `info_to_tags` emits no payer-history tags, preserving disabled-mode output byte-for-byte. Clients treat missing tags as disabled, and read the threshold tags to explain the `experienced_counterparties` metric (D-7) without hard-coding node policy. --- @@ -595,18 +669,23 @@ CREATE TABLE IF NOT EXISTS payer_history ( ); -- Distinct-counterparty set. counterparty_id is a keyed hash (D-7), never a pubkey. +-- `experienced` is the D-7 qualification snapshot taken at success time; it may +-- flip 0→1 on a LATER success with the same triple, never retroactively. CREATE TABLE IF NOT EXISTS payer_history_counterparties ( user_pubkey char(64) NOT NULL, payment_hash char(64) NOT NULL, counterparty_id char(64) NOT NULL, first_success_at integer NOT NULL, + experienced integer NOT NULL DEFAULT 0, -- 1 = counterparty qualified (D-7); monotone 0→1 PRIMARY KEY (user_pubkey, payment_hash, counterparty_id) ); ``` Notes -- `distinct_counterparties` is `COUNT(*)` on the third table; not denormalised - (the counterparty insert is `INSERT OR IGNORE`, so the count is exact). +- `distinct_counterparties` is `COUNT(*)` on the third table and + `experienced_counterparties` is `COUNT(*) WHERE experienced = 1`; not + denormalised (the counterparty write is an upsert keyed by the triple, so + both counts are exact). - No foreign keys: `orders` rows outlive declarations, and the bond tables set the precedent of not declaring FKs. - Sizes are trivial (two 64-char keys per successful trade). @@ -637,13 +716,60 @@ pub async fn find_declaration(pool, order_id: Uuid) -> Result, MostroError>; pub async fn take_declaration<'e, E: sqlx::Executor<'e>>(exec: E, order_id: Uuid) -> Result, MostroError>; // DELETE … RETURNING * (idempotency token) + +pub struct HistoryCounters { pub successful_trades: u32, + pub distinct_counterparties: u32, + pub experienced_counterparties: u32, // COUNT(*) WHERE experienced = 1 + pub first_success_at: Option, + pub last_success_at: Option } + pub async fn load_history(pool, user_pubkey: &str, hash: &str) - -> Result<(u32 /*trades*/, u32 /*cps*/, Option, Option), MostroError>; -pub async fn bump_history<'e, E>(exec: E, user_pubkey, hash, counterparty_id, now) - -> Result<(), MostroError>; // upsert payer_history + INSERT OR IGNORE counterparties + -> Result; +pub async fn bump_history<'e, E>(exec: E, user_pubkey, hash, counterparty_id, + experienced: bool, now) + -> Result<(), MostroError>; // upsert payer_history + counterparty upsert (SQL below) + +/// Counterparty qualification input (D-7). Reads `orders`, NOT the history +/// tables: the seller's whole node-side record counts, including trades that +/// predate this feature. Excludes `current_order` (already `Success` inside +/// the CAS transaction — the trade being recorded never counts) and every +/// trade with `buyer_pubkey` (only trades with OTHER buyers qualify — the +/// documented anti-Sybil choice, D-7). +pub struct SellerExperience { pub qualifying_trades: u32, + pub first_qualifying_at: Option } +pub async fn seller_experience<'e, E: sqlx::Executor<'e>>(exec: E, + seller_master_pubkey: &str, buyer_pubkey: &str, current_order: Uuid) + -> Result; pub async fn prune_declarations_for_terminal_orders(pool) -> Result; ``` +`bump_history`'s counterparty write — the qualification flag is monotone +(D-7): it can only ever go 0→1, on a later success, never back: + +```sql +INSERT INTO payer_history_counterparties + (user_pubkey, payment_hash, counterparty_id, first_success_at, experienced) +VALUES (?1, ?2, ?3, ?4, ?5) +ON CONFLICT(user_pubkey, payment_hash, counterparty_id) +DO UPDATE SET experienced = MAX(experienced, excluded.experienced); +``` + +`seller_experience`: + +```sql +SELECT COUNT(*) AS n, MIN(created_at) AS first_at + FROM orders + WHERE master_seller_pubkey = ?1 + AND status = 'success' + AND buyer_dispute = 0 AND seller_dispute = 0 -- "successful" means undisputed, as in D-6 + AND id <> ?2 -- the trade being recorded never counts (D-7) + AND master_buyer_pubkey <> ?3 -- trades with THIS buyer do not qualify (D-7) +``` + +`created_at` (order creation) is the only per-order timestamp available; it +overstates a trade's age by the trade's own duration, which is acceptable for +a days-granularity threshold and keeps the query to a single table. + Validation helper, used by the handler *and* by `bump_history`: ```rust @@ -761,23 +887,24 @@ pub async fn build_for_order(pool, node_keys: &Keys, order: &Order) let Some(decl) = db::find_declaration(pool, order.id).await? else { return Ok(None) }; let (normal_buyer_idkey, _) = order.is_full_privacy_order()?; let Some(user) = normal_buyer_idkey else { - return Ok(Some(PaymentHistory::unavailable(decl.payment_hash))); // D-4 + return Ok(Some(PaymentHistory::unavailable(decl.payment_hash))); // D-4: all counters zero }; - let (trades, cps, first, last) = db::load_history(pool, &user, &decl.payment_hash).await?; + let h = db::load_history(pool, &user, &decl.payment_hash).await?; Ok(Some(PaymentHistory { payment_hash: decl.payment_hash, buyer_mode: BuyerMode::Reputation, - successful_trades: trades, distinct_counterparties: cps, - first_success_at: first, last_success_at: last })) + successful_trades: h.successful_trades, + distinct_counterparties: h.distinct_counterparties, + experienced_counterparties: h.experienced_counterparties, + first_success_at: h.first_success_at, last_success_at: h.last_success_at })) } ``` -`Status::Success` is allowed for the query so a seller restoring a session can -still see what they saw at release time. Because the declaration row is consumed -on success (§10.5), `build_for_order` must handle that case: when the order is -`Success` and no declaration exists, answer `not_found` — the seller already -received the push at `fiat-sent` time and the value would now include the -trade just completed, which is a different (and confusing) number. Keep it -simple: *post-success queries return `not_found`.* (Listed in §17 as an open -question if client authors want otherwise.) +A seller restoring a session might re-query in `Status::Success`, but the +declaration row is consumed on success (§10.5), so the handler answers +`not_found` — the seller already received the push at `fiat-sent` time and +the value would now include the trade just completed, which is a different +(and confusing) number. Keep it simple: *post-success queries return +`not_found`.* (Listed in §17 as an open question if client authors want +otherwise.) ### 10.5 Success hook (`src/app/payer/success.rs`) — **D-5** @@ -812,7 +939,17 @@ pub async fn record_payer_success( let seller_master = order.get_master_seller_pubkey() .map(|k| k.to_string()).unwrap_or_else(|_| order.seller_pubkey.clone().unwrap_or_default()); let cp = counterparty_id(node_keys, &seller_master); // D-7 - db::bump_history(&mut **tx, &user, &decl.payment_hash, &cp, now()).await?; + // D-7 qualification snapshot. The CAS has already flipped this order to + // Success *inside this transaction*, so the current trade must be excluded + // explicitly: it never counts toward its own counterparty's qualification. + // Only history that predates this trade — and only trades with OTHER + // buyers — qualifies. + let exp = db::seller_experience(&mut **tx, &seller_master, &user, order.id).await?; + let (min_trades, min_days) = Settings::payer_history_experience_thresholds(); + let experienced = exp.qualifying_trades >= min_trades + && exp.first_qualifying_at + .is_some_and(|t| now() - t >= i64::from(min_days) * 86_400); + db::bump_history(&mut **tx, &user, &decl.payment_hash, &cp, experienced, now()).await?; Ok(()) } @@ -834,7 +971,13 @@ Why this is safe CAS transaction, a database failure rolls back the success transition and the declaration remains retryable by the existing payment-success retry paths. - It does not touch `orders`, respecting the "no full-row writes after - success" rule documented at `payment_success` in `src/app/release.rs`. + success" rule documented at `payment_success` in `src/app/release.rs` — + `seller_experience` only *reads* `orders`, inside the same transaction. +- The `experienced` flag is monotone (D-7): the counterparty upsert keeps + `experienced = MAX(experienced, excluded.experienced)` (§10.1), so a later + success can upgrade 0→1 but nothing downgrades 1→0, and rows from past + trades are never re-evaluated unless a new success lands — no retroactive + upgrades. - `CompletedByAdmin` / admin settle reach `Success` through the same `do_payment → payment_success` path and are covered; the dispute flags (D-6) decide whether they count. @@ -869,9 +1012,9 @@ MVP (the Cashu release path does not exist yet); see §13. | Party | Learns | Never learns | |---|---|---| -| Seller (via Mostro) | buyer's committed `payment_hash` for **this order**; aggregate counters for **this** (buyer, hash) pair; whether the buyer is in full-privacy mode (already visible today through `Peer.reputation == None`) | buyer identity key, other trade keys, other order ids, which sellers were the counterparties, any hash other than the one the buyer chose to commit to this order | +| Seller (via Mostro) | buyer's committed `payment_hash` for **this order**; aggregate counters for **this** (buyer, hash) pair — including how many of the buyer's past counterparties met the node's experience policy (D-7), as a bare count; whether the buyer is in full-privacy mode (already visible today through `Peer.reputation == None`) | buyer identity key, other trade keys, other order ids, which sellers were the counterparties, any single counterparty's qualification status, any hash other than the one the buyer chose to commit to this order | | Buyer | nothing new about the seller | — | -| Mostro node | `(master_buyer_pubkey, payment_hash)` association + counters; keyed counterparty hashes | plaintext payer details (D-2) | +| Mostro node | `(master_buyer_pubkey, payment_hash)` association + counters; keyed counterparty hashes; per-counterparty qualification snapshots (derived from `orders`, which the node already holds) | plaintext payer details (D-2) | | Public relays | nothing | everything in this feature | ### 11.2 Why there is no oracle @@ -889,13 +1032,18 @@ MVP (the Cashu release path does not exist yet); see §13. - There is no `same_user(a, b)` primitive and none is introduced (gist §12). - Counterparty identities are keyed hashes (D-7); even an exported DB does not list which sellers a buyer dealt with without the node secret. +- The experienced count is computed from `orders` — data the node already + holds — and is returned only as an aggregate, never per counterparty. The + query takes no parameters, so a seller cannot turn it into an "is seller X + experienced?" probe about a third party; they only ever learn how many of + *this* buyer's past counterparties qualified. ### 11.3 Residual risks (accepted for the MVP) | Risk | Mitigation / status | |---|---| | Node DB compromise reveals `(identity key, payment_hash)` pairs. Hashes are brute-forceable by anyone who already knows the candidate account. | Same trust boundary as `master_*_pubkey` and `buyer_invoice` today. History is inherently a node-side function; a node that wants less retention can purge (§18). | -| Buyer colludes with sellers to farm history. | Needs real successful trades with real sats, distinct counterparties and elapsed time (gist §32); tiers in §12 weight all three. | +| Buyer colludes with sellers to farm history. | Needs real successful trades with real sats, distinct counterparties and elapsed time (gist §32); tiers in §12 weight all four. Qualifying as an *experienced* counterparty additionally requires N prior successes with **other** buyers over D days (D-7), so a closed two-party Sybil ring no longer counts. | | Victim happens to be a Mostro user with history on the same account. | Only a problem under hash-only history (§18); D-1 keys on the *buyer's* identity, so the victim's history is not attributed to the attacker. | | Buyer declares a hash but sends different plaintext to the seller. | Seller's client recomputes and flags mismatch (D-3); treated like a sender mismatch — recommend dispute. | | Buyer in full-privacy mode gets no history forever. | Deliberate (D-4). Sellers who care can prefer reputation-mode buyers; clients must word it as "unavailable", not "new". | @@ -920,9 +1068,14 @@ Normative for clients that opt in (checked via the info-event tags, §8.3). plaintext from the buyer, recompute; if it differs, show a hard warning. 2. On `payment-history` (push or reply), render two independent blocks: *Sender match* (manual confirmation) and *Payment-account history*. -3. Never auto-release; never auto-refuse. The release screen shows both blocks +3. In the history block, render `experienced_counterparties` alongside the raw + counters, e.g. *"N of the buyer's past counterparties were already + experienced on this node when they traded with them"*. Read the thresholds + from the info-event tags (§8.3) — they are node policy (D-7), do not + hard-code them. +4. Never auto-release; never auto-refuse. The release screen shows both blocks above `[Release]` / `[Dispute]` (gist §22). -4. If no `payment-history` arrived by the time fiat is reported sent and the +5. If no `payment-history` arrived by the time fiat is reported sent and the node has the feature enabled, show *"Buyer did not declare a payment sender"* as its own warning. @@ -930,11 +1083,15 @@ Normative for clients that opt in (checked via the info-event tags, §8.3). | Tier | Condition (all of) | Wording | |---|---|---| -| 🟢 Established | `successful_trades ≥ 5`, `distinct_counterparties ≥ 3`, `first_success_at` ≥ 30 days ago | "Established payment account" | +| 🟢 Established | `successful_trades ≥ 5`, `distinct_counterparties ≥ 3`, `experienced_counterparties ≥ 1`, `first_success_at` ≥ 30 days ago | "Established payment account" | | 🟡 Limited | `successful_trades ≥ 1` and not Established | "Limited payment history" | | 🔴 New | `successful_trades == 0` (`buyer_mode == reputation`) | "No previous successful trades with this account" | | ⚪ Unavailable | `buyer_mode == full_privacy` | "History unavailable (buyer trades in full-privacy mode)" | +Requiring ≥ 1 experienced counterparty keeps a fresh Sybil cluster from +reaching *Established* by trading only with itself: at least one counterparty +must already have had real history with third parties at trade time (D-7). + Forbidden wording: "verified owner", "verified account", "trusted account" (gist §33). @@ -960,7 +1117,10 @@ All tests are in-file `#[cfg(test)]` modules using the existing scaffolding **`mostro-core`** - Round-trip serde for the three actions / two payloads / two reasons; exact - wire strings (`declare-payer`, `payer_declaration`, `invalid_payment_hash`). + wire strings (`declare-payer`, `payer_declaration`, `invalid_payment_hash`); + `PaymentHistory` round-trips with `experienced_counterparties` (older + clients simply ignore the unknown key — the struct has no + `deny_unknown_fields`). - `MessageKind::verify()` accepts / rejects the matrix in §6.4. **`declare_payer_action`** @@ -995,6 +1155,20 @@ All tests are in-file `#[cfg(test)]` modules using the existing scaffolding - `buyer_dispute = 1` → declaration consumed, no history row. - full-privacy buyer → declaration consumed, no history row. - `counterparty_id` is deterministic for a key and differs across keys. +- seller below the thresholds → counterparty row stored with `experienced = 0` + and `experienced_counterparties` reads 0; a seller with ≥ N qualifying + trades whose first qualifying trade is ≥ D days old → `experienced = 1`. +- the trade being recorded never counts: a seller whose N-th qualifying trade + is the current one does NOT qualify (D-7). +- buyer-relative exclusion: a seller whose prior successes were all with this + same buyer identity does NOT qualify, however many there were (D-7). +- disputed past trades count neither toward N nor toward D. +- upgrade path: a first success stores `experienced = 0`; once the seller has + crossed the thresholds with other buyers, a later success with the same + (buyer, hash, counterparty) triple flips the stored flag to 1, and the + `MAX()` upsert never flips it back. +- no retroactivity: the seller crossing the thresholds *after* a trade leaves + previously stored rows at `experienced = 0` until another success lands. **`payment_success` wiring** (extend `src/app/release.rs` tests with the `PayoutStatusLookup` stub pattern, `payment_success` tests in `src/app/release.rs`) @@ -1008,7 +1182,9 @@ All tests are in-file `#[cfg(test)]` modules using the existing scaffolding **Info event** - tags absent when disabled and present only when enabled (`src/nip33.rs` tests next to - `bond_policy_tags`). + `bond_policy_tags`), including the two threshold tags; `PayerHistorySettings` + defaults `experienced_min_trades = 5`, `experienced_min_days = 30` when the + keys are absent. --- @@ -1037,7 +1213,14 @@ after PH-1 / PH-0. Each `mostrod` PR must keep the existing suite green - [ ] With the feature on, the §5 flow works end-to-end against a test node: buyer declares, seller receives `payer-declared` and a `payment-history` push at `fiat-sent`, a second successful trade from the same account - shows `successful_trades = 1`, `distinct_counterparties = 1`. + shows `successful_trades = 1`, `distinct_counterparties = 1`, + `experienced_counterparties = 0` (the seller is below the thresholds). +- [ ] Seeding the seller with ≥ `experienced_min_trades` undisputed successes + against *other* buyers, the oldest ≥ `experienced_min_days` days old, + makes the next success report `experienced_counterparties = 1`; a seller + whose prior successes were all with this same buyer stays at 0 (D-7), + and a seller who crosses the thresholds only *after* the trade stays at + 0 until another success is recorded. - [ ] A full-privacy buyer yields `buyer_mode = full_privacy` and writes no history rows (DB asserted). - [ ] No new field appears on kinds 38383 / 38384; 38385 gains the §8.3 @@ -1087,12 +1270,14 @@ after PH-1 / PH-0. Each `mostrod` PR must keep the existing suite green ## 19. Summary The mechanism combines **sender verification** (done by the seller, assisted by -a hash forwarded through Mostro) with **payment-identity continuity** (three -aggregate counters computed by Mostro from successful trades only). On this -codebase it maps to: three additive `mostro-core` variants, one migration with -three private tables, one new handler module, a ten-line hook on the single -`Success` CAS, a prune job, two info-event tags and one protocol chapter — -all behind a flag that is off by default and leaves the daemon byte-for-byte -unchanged when disabled. A triangulation attacker can still make the sender -match; they cannot cheaply make `successful_trades = 47, -distinct_counterparties = 29, first_success_at = nine months ago`. +a hash forwarded through Mostro) with **payment-identity continuity** (four +aggregate counters computed by Mostro from successful trades only, one of them +a buyer-relative "was this counterparty already experienced?" snapshot — D-7). +On this codebase it maps to: three additive `mostro-core` variants, one +migration with three private tables, one new handler module, a short hook on +the single `Success` CAS, a prune job, four info-event tags and one protocol +chapter — all behind a flag that is off by default and leaves the daemon +byte-for-byte unchanged when disabled. A triangulation attacker can still make +the sender match; they cannot cheaply make `successful_trades = 47, +distinct_counterparties = 29, experienced_counterparties = 11, +first_success_at = nine months ago`.