8.4 KiB
Research: Proof of Work
Topic Summary
Full implementation of NIP-13 proof-of-work: validation/querying and generation. The mining function receives an OwnedEvent with a target difficulty and hashes until the difficulty is achieved, returning a HashedEvent. The design should support running in a separate thread (native) or a Web Worker (wasm).
Philosophy
From ref/building-nostr:
- PoW is framed as an optional, client-level heuristic — not a protocol requirement. Users can "use web of trust or proof of work heuristics to filter posts."
- Clients should have user-configurable policies for handling adversarial data. PoW is one tool among several (reputation, payment, WoT).
- Economic spam prevention (relay charging) is presented as an alternative to computational PoW.
- NIP-13 is not mentioned by name; the philosophy emphasizes user agency and optional mechanisms over mandated solutions.
Reference Implementation Analysis
applesauce
No PoW implementation found. The applesauce libraries focus on client UI utilities and do not include NIP-13 support.
ndk
No PoW mining or validation in NDK core. NDK does parse min_pow_difficulty from NIP-11 relay info documents (NDKRelayInformation.limitation.min_pow_difficulty), but does not act on it. PoW mining is delegated to external packages.
nostr-gadgets
No PoW implementation found. The library focuses on hints scoring, database utilities, event sets, and outbox logic.
nostrlib
No PoW-specific findings from the Go library search. The library may handle it elsewhere but no dedicated NIP-13 module was found.
nostr-tools
File: nip13.ts
Complete standalone NIP-13 implementation:
getPow(hex: string): number— Counts leading zero bits from hex string by iterating 8-bit nibbles.getPowFromBytes(hash: Uint8Array): number— Byte-based version usingMath.clz32()for the partial byte.minePow(unsigned: UnsignedEvent, difficulty: number)— Synchronous mining on main thread:- Appends
["nonce", count, difficulty]tag - Increments nonce counter until SHA-256 hash meets difficulty threshold
- Uses
@noble/hashesfor SHA-256 - Mutates
created_atwhen time rolls over - Returns event with computed
idfield
- Appends
Design: Simple, synchronous, single-threaded. No worker support. Minimal dependencies.
Test vectors: Validates getPow against known hashes with specific difficulties (0–73 bits). Tests minePow with difficulty=10.
rust-nostr
Files:
crates/nostr/src/nips/nip13.rs— Core PoW utilitiescrates/nostr/src/event/builder.rs— Mining loopcrates/nostr/src/event/id.rs— Validationcrates/nostr/src/event/tag/standard.rs— POW tag structure
Data Structures:
TagStandard::POW { nonce: u128, difficulty: u8 }
// Serializes to: ["nonce", "<nonce>", "<difficulty>"]
EventBuilder { pow: Option<u8>, ... }
Core Algorithm — get_leading_zero_bits<T: AsRef<[u8]>>(h: T) -> u8:
- Iterates bytes: full zero byte → +8 bits; non-zero byte →
leading_zeros()CPU intrinsic; return - Range 0–255
Prefix Generation — get_prefixes_for_difficulty(bits: u8) -> Vec<String>:
- Converts bit difficulty to valid hex prefixes for filtering/querying
- Formula:
prefix_count = 2^(hex_bits - difficulty_bits)
Single-Threaded Mining (mine_pow_single_thread):
- Increments nonce from 0
- For each nonce: push POW tag → compute EventId (SHA-256) → check leading zeros ≥ difficulty → pop tag if fail
- Returns
UnsignedEventon success
Multi-Threaded Mining (mine_pow_multi_thread, feature pow-multi-thread):
- Spawns
thread::available_parallelism()threads - Each thread: starting nonce =
thread_id, stride =num_threads - Coordination:
Arc<AtomicBool>withOrdering::Relaxed - First thread to find solution signals others to stop
- Main thread busy-waits on atomic flag
- Falls back to single-threaded if 1 core or spawn failure
Validation:
impl EventId {
pub fn check_pow(&self, difficulty: u8) -> bool {
nip13::get_leading_zero_bits(self.as_bytes()) >= difficulty
}
}
Relay enforcement: nostr-relay-builder checks min_pow before accepting events.
Design decisions:
nonce: u128— virtually inexhaustible nonce spacedifficulty: u8— matches SHA-256 bit width- Feature-gated multi-threading keeps
no_stdcompatibility - Busy-wait polling (not ideal but simple)
- Timestamp captured once before mining, shared across threads
welshman
File: packages/util/src/Pow.ts
Worker-based asynchronous mining:
-
makePow(event: OwnedEvent, difficulty: number): ProofOfWork- Returns
{ worker: Worker, result: Promise<HashedEvent> } - Creates Web Worker from inline
BlobURL - Worker receives event + difficulty via
postMessage - Worker runs mining loop using
crypto.subtle.digest("SHA-256", ...) - Nonce tag mutated in-place for performance:
tag[1] = count.toString() - Supports
startandstepparameters for distributing across multiple workers
- Returns
-
getPow(event: HashedEvent): number— Validates difficulty from event hash. Counts leading zero bytes, then usesMath.clz32()on final byte. -
estimateWork(difficulty: number)— Cost estimation using benchmark:benchmark_ms * 2^(difficulty - benchmarkDifficulty).benchmarkDifficulty = 15.
Integration in thunk.ts:
- PoW applied before signing (necessary because nonce changes the event ID)
- For gift-wrapped events (NIP-59): PoW on the wrapper
- Uses
AbortSignal.timeout(30_000)for timeout protection - Logs warning if event already signed (PoW would change ID, invalidating signature)
Design: Non-blocking via Workers. Single worker by default but architecture supports multi-worker distribution via start/step. Uses Web Crypto API (available in workers).
Common Patterns
-
Difficulty metric: All implementations use leading zero bits in SHA-256 hash (NIP-13 standard). Not byte-aligned — allows fine-grained difficulty.
-
Tag format: Universal
["nonce", "<counter>", "<target_difficulty>"]tag. -
Mining loop: Increment nonce, recompute hash, check leading zeros. Simple brute force — no shortcuts possible.
-
PoW before signing: All implementations compute PoW before the event is signed, since the nonce tag changes the event ID which is what gets signed.
-
Leading zero counting: Two approaches:
- Byte iteration + CPU intrinsic (
leading_zeros()/Math.clz32()) - Hex string nibble iteration (nostr-tools)
- Byte iteration + CPU intrinsic (
-
Parallelism strategies diverge:
- rust-nostr: OS threads with atomic bool coordination
- welshman: Web Workers with message passing
- nostr-tools: no parallelism (synchronous)
Considerations for Our Implementation
API Design
The chapter should take an OwnedEvent (has pubkey but no hash yet) and return a HashedEvent (has computed ID meeting difficulty). This fits naturally into the event lifecycle from chapter 05.
The mining function should be pure and blocking — it takes inputs and returns a result. Threading/worker dispatch is the caller's responsibility. This keeps the core function platform-agnostic:
- Native: caller wraps in
std::thread::spawnortokio::spawn_blocking - WASM: caller runs in a Web Worker
Validation
Two levels:
get_leading_zero_bits(hash: &[u8]) -> u8— pure utilitycheck_pow(event: &HashedEvent, difficulty: u8) -> bool— checks event's nonce tag difficulty matches actual hash difficulty
Querying
get_prefixes_for_difficulty(difficulty: u8) -> Vec<String> enables relay-side filtering by event ID prefix. Useful for REQ filters.
Dependencies
- SHA-256 already available from the events chapter
- No additional crates needed for single-threaded PoW
u128nonce type is standard Rust
Threading Considerations
Rather than building threading into the mining function:
- Keep
mine_pow(event: OwnedEvent, difficulty: u8) -> HashedEventas a blocking function - Document how callers can parallelize (thread per core, strided nonces via start/step parameters)
- For WASM: the function runs inside a worker; the host page dispatches to it
This matches welshman's approach and avoids platform-specific code in the core library.
Nonce Strategy
Support start and step parameters to enable callers to distribute work:
mine_pow(event: OwnedEvent, difficulty: u8, start: u64, step: u64) -> HashedEvent
- Single thread:
start=0, step=1 - N threads: thread i uses
start=i, step=N