feat(reputation): verifiable payment tier via NIP-57 zap receipts
A trade proof of ['proof','zap','<receiptId>'] 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.
This commit is contained in:
parent
eec56db124
commit
50735d1718
5 changed files with 109 additions and 4 deletions
|
|
@ -942,8 +942,13 @@ session with **no other memory of this project**. So:
|
||||||
relay yet**, so the honest values are 0 today. Related: `useTradeReputation`
|
relay yet**, so the honest values are 0 today. Related: `useTradeReputation`
|
||||||
now exposes **`withPaymentProof`** — mutually-confirmed trades that carry a
|
now exposes **`withPaymentProof`** — mutually-confirmed trades that carry a
|
||||||
`proof` tag — surfaced on `TradeReputationCard` as *"with payment proof
|
`proof` tag — surfaced on `TradeReputationCard` as *"with payment proof
|
||||||
(self-reported — not verified)"*, **never** as verification. (Remaining
|
(self-reported — not verified)"*, **never** as verification. **The one
|
||||||
anti-sybil: ring detection + PoW.)
|
verifiable tier (2026-10-01):** `["proof","zap","<receiptId>"]` 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).**
|
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
|
||||||
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
||||||
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
||||||
|
|
|
||||||
2
NIP.md
2
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) |
|
| `p` | The **other party** in the trade (required by us, marks a trade attestation) |
|
||||||
| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` |
|
| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` |
|
||||||
| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` |
|
| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` |
|
||||||
| `proof` | Optional `["proof","lninvoice","<payment_hash>"]` 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","<payment_hash>"]` — a self-reported hash (a **claim**). `["proof","zap","<receiptId>"]` — 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. |
|
| `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
|
`method`/`amount`/`rating` are **self-reported**, never proof of payment or a
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
proof attached <span className="text-gray-600">(self-reported — not verified)</span>
|
proof attached <span className="text-gray-600">(self-reported — not verified)</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{data.paymentVerified > 0 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> payment-verified
|
||||||
|
<span className="text-gray-600"> (zap receipt)</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{singlePartnerWarning && (
|
{singlePartnerWarning && (
|
||||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||||
⚠ Every confirmation is with the same account. Mutual confirmation is
|
⚠ Every confirmation is with the same account. Mutual confirmation is
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { verifyZapReceipt } from '@/lib/zapReceipt';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||||
|
|
||||||
|
|
@ -157,6 +158,11 @@ export interface TradeReputation {
|
||||||
* not "verified".
|
* not "verified".
|
||||||
*/
|
*/
|
||||||
withPaymentProof: number;
|
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.
|
* Distinct counterparties with at least one mutually-confirmed trade.
|
||||||
*
|
*
|
||||||
|
|
@ -189,7 +195,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
enabled: !!pubkey,
|
enabled: !!pubkey,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async (c): Promise<TradeReputation> => {
|
queryFn: async (c): Promise<TradeReputation> => {
|
||||||
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;
|
if (!pubkey) return empty;
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
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<string, string>(); // 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 {
|
return {
|
||||||
sales,
|
sales,
|
||||||
purchases,
|
purchases,
|
||||||
verified,
|
verified,
|
||||||
withPaymentProof,
|
withPaymentProof,
|
||||||
|
paymentVerified,
|
||||||
verifiedCounterparties: verifiedWith.size,
|
verifiedCounterparties: verifiedWith.size,
|
||||||
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
||||||
counterparties: [...counterparties],
|
counterparties: [...counterparties],
|
||||||
|
|
|
||||||
66
src/lib/zapReceipt.ts
Normal file
66
src/lib/zapReceipt.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Verifying a payment claim that CAN be checked: a **NIP-57 zap receipt**.
|
||||||
|
*
|
||||||
|
* A `31871` may carry `["proof","zap","<receiptId>"]`. 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue