diff --git a/AGENTS.md b/AGENTS.md index 383c895..1396e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -942,8 +942,13 @@ session with **no other memory of this project**. So: relay yet**, so the honest values are 0 today. Related: `useTradeReputation` now exposes **`withPaymentProof`** — mutually-confirmed trades that carry a `proof` tag — surfaced on `TradeReputationCard` as *"with payment proof - (self-reported — not verified)"*, **never** as verification. (Remaining - anti-sybil: ring detection + PoW.) + (self-reported — not verified)"*, **never** as verification. **The one + verifiable tier (2026-10-01):** `["proof","zap",""]` is checked by + `src/lib/zapReceipt.ts` (`verifyZapReceipt`: kind-9735 signature + `p` = seller + + decodable bolt11 amount + the embedded 9734 request) and counted as + **`paymentVerified`** — shown as *"payment-verified (zap receipt)"*. Honest + ceiling: it proves the seller's **LNURL provider** acknowledged a payment, not + that goods changed hands. (Remaining anti-sybil: ring detection + PoW.) - **Reverse geocoding hits Nominatim — disclosed (2026-10-01).** `src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` / `pushRegionTags`) is the **single source of truth**, used by **spot create, spot diff --git a/NIP.md b/NIP.md index 93a857e..8b6cf3e 100644 --- a/NIP.md +++ b/NIP.md @@ -3234,7 +3234,7 @@ The base NIP has no counterparty field for this use case, so we add: | `p` | The **other party** in the trade (required by us, marks a trade attestation) | | `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` | | `amount` | Optional self-reported amount, `["amount","45000","SATS"]` | -| `proof` | Optional `["proof","lninvoice",""]` when the payer paid a BOLT11 that carried a `payment_hash` (e.g. from an accepted offer's `payment` pointer). | +| `proof` | Optional payment evidence. `["proof","lninvoice",""]` — a self-reported hash (a **claim**). `["proof","zap",""]` — a NIP-57 zap receipt the app can **verify** (signature + recipient + amount), which the UI shows as **payment-verified**. | | `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. | `method`/`amount`/`rating` are **self-reported**, never proof of payment or a diff --git a/src/components/TradeReputationCard.tsx b/src/components/TradeReputationCard.tsx index 9b74124..9a04d05 100644 --- a/src/components/TradeReputationCard.tsx +++ b/src/components/TradeReputationCard.tsx @@ -67,6 +67,12 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla proof attached (self-reported — not verified)

)} + {data.paymentVerified > 0 && ( +

+ {data.paymentVerified} payment-verified + (zap receipt) +

+ )} {singlePartnerWarning && (

⚠ Every confirmation is with the same account. Mutual confirmation is diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index 917e954..ec92524 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { verifyZapReceipt } from '@/lib/zapReceipt'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; @@ -157,6 +158,11 @@ export interface TradeReputation { * not "verified". */ withPaymentProof: number; + /** + * Trades whose `proof` is a **verified** zap receipt (kind 9735 — signature, + * recipient and amount all checked). The only client-verifiable tier today. + */ + paymentVerified: number; /** * Distinct counterparties with at least one mutually-confirmed trade. * @@ -189,7 +195,7 @@ export function useTradeReputation(pubkey: string | undefined) { enabled: !!pubkey, staleTime: 5 * 60 * 1000, queryFn: async (c): Promise => { - const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; + const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, withPaymentProof: 0, paymentVerified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] }; if (!pubkey) return empty; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); @@ -257,11 +263,33 @@ export function useTradeReputation(pubkey: string | undefined) { } } + // Payment-VERIFIED tier: only where a `proof` names a zap receipt we can + // actually check. Collect them, fetch once, verify each. + const zapProofs = new Map(); // receiptId → expected seller + for (const ev of unique) { + const proof = ev.tags.find(([t]) => t === 'proof'); + if (proof?.[1] !== 'zap' || !proof[2]) continue; + const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]); + if (coord) zapProofs.set(proof[2], coord.pubkey); + } + let paymentVerified = 0; + if (zapProofs.size > 0) { + const receipts = await nostr.query( + [{ kinds: [9735], ids: [...zapProofs.keys()], limit: 100 }], + { signal }, + ); + for (const r of receipts) { + const expected = zapProofs.get(r.id); + if (expected && verifyZapReceipt(r, expected).valid) paymentVerified += 1; + } + } + return { sales, purchases, verified, withPaymentProof, + paymentVerified, verifiedCounterparties: verifiedWith.size, verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })), counterparties: [...counterparties], diff --git a/src/lib/zapReceipt.ts b/src/lib/zapReceipt.ts new file mode 100644 index 0000000..17a990e --- /dev/null +++ b/src/lib/zapReceipt.ts @@ -0,0 +1,66 @@ +/** + * Verifying a payment claim that CAN be checked: a **NIP-57 zap receipt**. + * + * A `31871` may carry `["proof","zap",""]`. A zap receipt (kind 9735) + * is signed by the recipient's **LNURL server** after it received the payment, + * so — unlike a hand-typed invoice hash — it is independently checkable by + * anyone: we fetch the receipt, verify its signature, that it pays the expected + * seller, and what it paid. + * + * ⚠️ Honest ceiling: the receipt is only as trustworthy as the seller's own + * LNURL provider. It proves *that provider* acknowledged a payment to that + * seller — not that goods changed hands. Still, it is a real cryptographic + * signal, a tier above a free-text claim. + */ +import { verifyEvent } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { getSatoshisFromBolt11 } from '@/lib/bolt11'; + +export interface ZapReceiptCheck { + valid: boolean; + sats: number; + reason?: string; +} + +/** + * Verify a kind-9735 zap receipt pays `expectedRecipient`. + * Never throws — returns `{ valid: false, reason }` on any problem. + */ +export function verifyZapReceipt(receipt: NostrEvent, expectedRecipient: string): ZapReceiptCheck { + if (receipt.kind !== 9735) return { valid: false, sats: 0, reason: 'not a zap receipt' }; + if (!verifyEvent(receipt)) return { valid: false, sats: 0, reason: 'bad receipt signature' }; + + const recipient = receipt.tags.find(([n]) => n === 'p')?.[1]; + if (recipient !== expectedRecipient) return { valid: false, sats: 0, reason: 'recipient mismatch' }; + + const bolt11 = receipt.tags.find(([n]) => n === 'bolt11')?.[1]; + if (!bolt11) return { valid: false, sats: 0, reason: 'missing invoice' }; + + let sats = 0; + try { + sats = getSatoshisFromBolt11(bolt11); + } catch { + return { valid: false, sats: 0, reason: 'undecodable invoice' }; + } + if (!Number.isFinite(sats) || sats <= 0) return { valid: false, sats: 0, reason: 'no amount' }; + + // The embedded `description` is the original zap request (kind 9734); when + // present it must also name the same recipient. + const description = receipt.tags.find(([n]) => n === 'description')?.[1]; + if (description) { + try { + const request = JSON.parse(description) as NostrEvent; + if (request.kind && request.kind !== 9734) { + return { valid: false, sats, reason: 'description is not a zap request' }; + } + const requestRecipient = request.tags?.find(([n]) => n === 'p')?.[1]; + if (requestRecipient && requestRecipient !== expectedRecipient) { + return { valid: false, sats, reason: 'zap request recipient mismatch' }; + } + } catch { + return { valid: false, sats, reason: 'unparsable description' }; + } + } + + return { valid: true, sats }; +}