SkateSpots/src/lib/pow.ts
SkateSpots a12db574d4 feat(marketplace): NIP-13 proof of work - free CPU anti-spam
pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free
CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the
UNSIGNED event before signing (id is signature-independent -> one signer prompt).
CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer
chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free
alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW
badge at >=16.
2026-10-01 16:42:10 +07:00

83 lines
3 KiB
TypeScript

/**
* NIP-13 proof of work — a **free** (CPU-only) anti-spam tax.
*
* A `["nonce", <nonce>, <target>]` 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));
}
}
}