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.
83 lines
3 KiB
TypeScript
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));
|
|
}
|
|
}
|
|
}
|