/** * 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)); } } }