diff --git a/AGENTS.md b/AGENTS.md index e28f925..2c2c98f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,9 +143,18 @@ session with **no other memory of this project**. So: ~0 even when cross-confirmed. **Ring analysis** — `useTradeRings` does a **2-hop** query of the partners' own attestations and measures **closedness** (share of possible partner↔partner trades); `≥3` partners with `≥0.5` - closedness flags a closed cluster. ⚠️ **PoW (NIP-13) is the remaining lever** - but requires **mining at publish** (and a read-side verifier) — a verifier - alone does nothing because no events carry a `nonce` yet. Deferred. + closedness flags a closed cluster. + - **NIP-13 proof of work (2026-10-01):** `src/lib/pow.ts` (`minePow` / + `verifyPow` / `countLeadingZeroBits`). ⚠️ **PoW pays no one** — it is a free + **CPU** tax, not a fee; don't describe it as a payment. `useNostrPublish` + accepts `pow?: number` and **mines the nonce on the UNSIGNED event before + signing** (the id is signature-independent, so the signer is still prompted + once). `CreateListingPage` sends **16 bits** on every listing (~80 ms) and + **20 bits** when a **newcomer picks work instead of the 1,000-sat listing + fee** (`SpamFeePayment.onPowInstead` → the free alternative). `parseMarketplaceItem` + exposes `powDifficulty` and `MarketplaceItemCard` shows a **⚡ PoW badge** at + ≥16. It raises cost, it doesn't prove humanity; keep targets low (mining runs + on the main thread with periodic yields). - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index e08e2a4..9538f44 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -343,6 +343,15 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa : '📍📦 Pickup or ship'} )} + {typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && ( + + ⚡ PoW {item.powDifficulty} + + )} {sellerRep && sellerRep.verified > 0 && ( diff --git a/src/components/SpamFeePayment.tsx b/src/components/SpamFeePayment.tsx index 9e1767e..075c6da 100644 --- a/src/components/SpamFeePayment.tsx +++ b/src/components/SpamFeePayment.tsx @@ -14,6 +14,8 @@ interface SpamFeePaymentProps { feeRecipient: string; feeRecipientName: string; onPaymentSuccess: () => void; + /** Offered as a FREE alternative to the sats fee: mine NIP-13 proof of work. */ + onPowInstead?: () => void; onSkip?: () => void; } @@ -23,6 +25,7 @@ export function SpamFeePayment({ feeRecipient, feeRecipientName, onPaymentSuccess, + onPowInstead, }: SpamFeePaymentProps) { const { toast } = useToast(); const { webln, activeNWC } = useWallet(); @@ -210,15 +213,33 @@ export function SpamFeePayment({

~${(LISTING_FEE_SATS * 0.0006).toFixed(2)} USD • One-time fee

- {/* Initial state - generate invoice button */} + {/* Initial state — pay, or prove work instead (free) */} {paymentState === 'idle' && ( - +
+ + {onPowInstead && ( + <> +

or

+ +

+ Spend a few seconds of CPU instead of {LISTING_FEE_SATS.toLocaleString()} sats. No + money, no wallet — a spam tax, not a payment. +

+ + )} +
)} {/* Generating invoice */} diff --git a/src/hooks/useNostrPublish.ts b/src/hooks/useNostrPublish.ts index 8bae035..26356f1 100644 --- a/src/hooks/useNostrPublish.ts +++ b/src/hooks/useNostrPublish.ts @@ -3,6 +3,7 @@ import { useMutation, type UseMutationResult } from "@tanstack/react-query"; import { useCurrentUser } from "./useCurrentUser"; import { isOffline, queueSignedEvent } from "@/lib/offlineCache"; +import { minePow } from "@/lib/pow"; import { toast } from "@/hooks/useToast"; import type { NostrEvent, NPool } from "@nostrify/nostrify"; @@ -37,24 +38,37 @@ export function useNostrPublish(): UseMutationResult { const { user } = useCurrentUser(); return useMutation({ - mutationFn: async (t: Omit) => { + mutationFn: async (t: Omit & { pow?: number }) => { if (!user) { throw new Error("You must be logged in to publish events"); } - const tags = t.tags ?? []; + const tags = [...(t.tags ?? [])]; // Add the client tag if it doesn't exist if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) { tags.push(["client", location.hostname]); } + const createdAt = t.created_at ?? Math.floor(Date.now() / 1000); + + // Optional NIP-13 proof of work. Mined on the UNSIGNED event — the id does + // not depend on the signature — so the signer is still prompted only once. + let finalTags = tags; + if (t.pow && t.pow > 0) { + const { nonce } = await minePow( + { pubkey: user.pubkey, kind: t.kind, created_at: createdAt, tags, content: t.content ?? "" }, + t.pow, + ); + finalTags = [...tags.filter(([n]) => n !== "nonce"), ["nonce", nonce, String(t.pow)]]; + } + // Sign the event const event = await user.signer.signEvent({ kind: t.kind, content: t.content ?? "", - tags, - created_at: t.created_at ?? Math.floor(Date.now() / 1000), + tags: finalTags, + created_at: createdAt, }); // Offline: don't sit through a 15s publish timeout before giving up. diff --git a/src/lib/pow.ts b/src/lib/pow.ts new file mode 100644 index 0000000..af6d7f6 --- /dev/null +++ b/src/lib/pow.ts @@ -0,0 +1,83 @@ +/** + * NIP-13 proof of work — a **free** (CPU-only) anti-spam tax. + * + * A `["nonce", , ]` tag plus an event id with at least `target` + * leading zero bits. Mining varies the nonce and recomputes the id (which does + * NOT depend on the signature), so we **mine before signing** and sign once — + * no extra signer prompts. Verification is a hash-count, free for everyone. + * + * Nobody receives anything: it costs the sender a little electricity and makes + * mass-minting expensive. See `CreateListingPage` for the fee-or-work gate. + */ +import { getEventHash } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export const NONCE_TAG = 'nonce'; + +/** Number of leading zero BITS in a hex string (the NIP-13 difficulty). */ +export function countLeadingZeroBits(hex: string): number { + let bits = 0; + for (const ch of hex) { + const nibble = parseInt(ch, 16); + if (Number.isNaN(nibble)) return bits; + if (nibble === 0) { + bits += 4; + continue; + } + bits += Math.clz32(nibble) - 28; // 1→3, 2→2, 4→1, 8→0 + break; + } + return bits; +} + +export interface PowProof { + valid: boolean; + /** Actual leading-zero bits of the event id. */ + difficulty: number; + /** The target the author claimed. */ + target: number; +} + +/** Check a signed event's PoW. Cheap — a single hash count. */ +export function verifyPow(event: NostrEvent): PowProof { + const tag = event.tags.find(([n]) => n === NONCE_TAG); + if (!tag) return { valid: false, difficulty: 0, target: 0 }; + const target = Number(tag[2] ?? 0); + const difficulty = countLeadingZeroBits(event.id); + return { valid: target > 0 && difficulty >= target, difficulty, target }; +} + +export interface MineOptions { + signal?: AbortSignal; + onProgress?: (attempts: number) => void; +} + +/** + * Mine a nonce so the resulting event id has >= `target` leading zero bits. + * Yields to the event loop periodically so the UI stays responsive; cancellable. + */ +export async function minePow( + template: { pubkey: string; kind: number; created_at: number; tags: string[][]; content: string }, + target: number, + options: MineOptions = {}, +): Promise<{ nonce: string; id: string; difficulty: number; target: number }> { + const baseTags = template.tags.filter(([n]) => n !== NONCE_TAG); + let attempts = 0; + // Random start so two clients never mine the same nonce sequence. + let counter = Math.floor(Math.random() * 0xffff); + + for (;;) { + if (options.signal?.aborted) throw new Error('Proof-of-work cancelled'); + counter += 1; + const nonce = `${counter.toString(36)}-${Math.floor(Math.random() * 0xffff).toString(36)}`; + const tags = [...baseTags, [NONCE_TAG, nonce, String(target)]]; + const id = getEventHash({ ...template, tags } as NostrEvent); + attempts += 1; + const difficulty = countLeadingZeroBits(id); + if (difficulty >= target) return { nonce, id, difficulty, target }; + if (attempts % 4000 === 0) { + options.onProgress?.(attempts); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index c6470ad..40f72d4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,6 @@ import type { NostrEvent } from '@nostrify/nostrify'; import ngeohash from 'ngeohash'; +import { countLeadingZeroBits } from '@/lib/pow'; import { parseCrewVideo } from '@/lib/videoEmbed'; import { isAdminPubkey } from '@/lib/admins'; @@ -3555,6 +3556,8 @@ export interface MarketplaceItem { size?: string; /** How the deal is fulfilled: local pickup, shipping, or either. */ fulfilment?: 'pickup' | 'ships' | 'both'; + /** NIP-13 proof-of-work difficulty (leading zero bits) on the event id. */ + powDifficulty?: number; expiration?: number; // Unix timestamp images: string[]; location?: string; @@ -3635,6 +3638,9 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null const condition = event.tags.find(([name]) => name === 'condition')?.[1] as MarketplaceItem['condition']; const listingType = event.tags.find(([name]) => name === 'listing_type')?.[1] as MarketplaceItem['listingType']; const fulfilment = event.tags.find(([name]) => name === 'fulfilment')?.[1] as MarketplaceItem['fulfilment']; + const powDifficulty = event.tags.some(([name]) => name === 'nonce') + ? countLeadingZeroBits(event.id) + : undefined; const size = event.tags.find(([name]) => name === 'size')?.[1]; const expirationTag = event.tags.find(([name]) => name === 'expiration')?.[1]; const expiration = expirationTag ? parseInt(expirationTag) : undefined; @@ -3669,6 +3675,7 @@ export function parseMarketplaceItem(event: NostrEvent): MarketplaceItem | null condition, listingType, fulfilment, + powDifficulty, size, expiration, images, diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx index 1d81a08..5d92904 100644 --- a/src/pages/CreateListingPage.tsx +++ b/src/pages/CreateListingPage.tsx @@ -47,6 +47,7 @@ export default function CreateListingPage() { // Anti-spam restrictions const restrictions = useListingRestrictions(linkedSpot?.pubkey); const [feePaid, setFeePaid] = useState(false); + const [powBypass, setPowBypass] = useState(false); const [, setLastListingRecord] = useLocalStorage<{ timestamp: number; spotOwnerPubkey?: string } | null>( 'last-listing-record', null @@ -309,6 +310,9 @@ export default function CreateListingPage() { kind: MARKETPLACE_ITEM_KIND, content: description.trim(), tags, + // NIP-13 proof of work — a free anti-spam tax (CPU, we never pay anyone). + // Invisible on a normal listing; a higher target stands in for the sats fee. + pow: powBypass ? 20 : 16, }); // Record listing creation for cooldown tracking (only for unknown trust level) @@ -516,6 +520,10 @@ export default function CreateListingPage() { feeRecipient={restrictions.feeRecipient} feeRecipientName={restrictions.feeRecipientName || 'SkateSpots'} onPaymentSuccess={() => setFeePaid(true)} + onPowInstead={() => { + setPowBypass(true); + setFeePaid(true); + }} /> )} @@ -527,7 +535,9 @@ export default function CreateListingPage() {
- Fee Paid! + + {powBypass ? 'Proof of work accepted!' : 'Fee Paid!'} + You can now create your listing.