From 50735d171843ee176b9a01e1564cb38329ea5801 Mon Sep 17 00:00:00 2001
From: SkateSpots
Date: Thu, 1 Oct 2026 16:14:55 +0700
Subject: [PATCH] feat(reputation): verifiable payment tier via NIP-57 zap
receipts
A trade proof of ['proof','zap',''] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
---
AGENTS.md | 9 +++-
NIP.md | 2 +-
src/components/TradeReputationCard.tsx | 6 +++
src/hooks/useTradeAttestations.ts | 30 +++++++++++-
src/lib/zapReceipt.ts | 66 ++++++++++++++++++++++++++
5 files changed, 109 insertions(+), 4 deletions(-)
create mode 100644 src/lib/zapReceipt.ts
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 };
+}