6.0 KiB
Plan: Proof of Work
Topic Summary
Full implementation of NIP-13 proof-of-work: validation and generation. The mining function
receives an OwnedEvent reference with a target difficulty and hashes until the difficulty
is achieved, returning a HashedEvent. The API is batch-based and pure — no threading, no
async, no platform-specific code. Callers control the loop and can parallelize or cancel by
managing batch ranges externally.
Chapter Outline
-
Introduction — What PoW means in nostr: leading zero bits on the SHA-256 event id. Why it exists (spam resistance, relay filtering, signal of effort). Reference NIP-13.
-
Module setup — Add
pub mod pow;tocoracle-lib/src/lib.rs. Module imports. -
Counting leading zero bits —
get_leading_zero_bits(hash: &[u8]) -> u8. Pure utility that iterates bytes: full zero byte = +8 bits, partial byte usesleading_zeros(). This is the foundation everything else builds on. -
The nonce tag — NIP-13 specifies a
["nonce", "<counter>", "<difficulty>"]tag. Explain that the nonce is part of the event content that gets hashed — changing it changes the id. Show how to add/read the tag using the existingTagstype. -
Validation —
check_pow(event: &HashedEvent, difficulty: u8) -> bool. Checks that the event id has at leastdifficultyleading zero bits AND that the nonce tag's claimed difficulty is at leastdifficulty. Both checks are needed: the id check proves the work was done, the tag check proves the miner intended at least this difficulty. -
Mining —
mine_pow(event: &OwnedEvent, difficulty: u8, start: Option<u64>, count: Option<u64>) -> Option<HashedEvent>. Clones the event's tags, appends/updates a nonce tag, hashes, checks difficulty. Iterates nonces fromstart(default 0) for up tocountattempts (default unbounded). ReturnsSome(HashedEvent)on success,Noneif the batch is exhausted. -
Usage patterns — Brief prose (not tangled code) showing:
- Simple:
mine_pow(&event, 20, None, None)— blocks until done - Batched: loop calling with
Some(cursor)andSome(100_000) - Parallel: N threads/workers with non-overlapping ranges
- WASM: worker runs batches, main thread stops sending to cancel
- Simple:
-
What's next — Transition to filters chapter.
API Design
/// Count the leading zero bits in a byte slice.
pub fn get_leading_zero_bits(hash: &[u8]) -> u8
/// Check that an event meets a minimum proof-of-work difficulty.
/// Returns true if the event id has at least `difficulty` leading zero bits
/// AND the nonce tag claims at least `difficulty`.
pub fn check_pow(event: &HashedEvent, difficulty: u8) -> bool
/// Mine proof-of-work for an event.
///
/// Tries nonces starting from `start` (default 0) for up to `count` attempts
/// (default unbounded). Returns `Some(HashedEvent)` with the nonce tag included
/// if a valid nonce is found, `None` if the batch is exhausted.
pub fn mine_pow(
event: &OwnedEvent,
difficulty: u8,
start: Option<u64>,
count: Option<u64>,
) -> Option<HashedEvent>
Code Organization
- Crate:
coracle-lib - Module:
coracle-lib/src/pow.rs - Exports:
get_leading_zero_bits,check_pow,mine_pow - Dependencies on existing code:
OwnedEvent,HashedEventfromevents,Tagsfromtags - Add
pub mod pow;tocoracle-lib/src/lib.rs
Dependencies
No new external crates. SHA-256 (sha2) and hex encoding are already available from the
events chapter. Leading zero bit counting uses only u8::leading_zeros() from std.
Narrative Notes
-
Why both id check and tag check: An event with 25 leading zeros but a nonce tag claiming difficulty 10 only committed to 10 bits of work — the extra zeros are luck. Relays that require difficulty 20 should reject it. Explain this clearly.
-
Why batch-based: The chapter should explain the design choice. A blocking
loop until foundfunction can't be cancelled and can't be parallelized. By exposing start/count, the library stays pure and platform-agnostic while giving callers full control. Show how this maps to threads (native) and workers (WASM). -
Nonce tag placement: The tag must be part of the serialized event before hashing. The mining loop adds the tag, hashes, checks, and either returns or tries the next nonce. Work on a clone of the input's tags — don't mutate the original.
-
Difficulty semantics: Difficulty is measured in leading zero bits, not bytes or hex characters. Difficulty 20 means the first 20 bits of the 256-bit hash are zero. This allows fine-grained difficulty that isn't locked to 4-bit (hex) or 8-bit (byte) boundaries.
Design Decisions
-
Batch-based API over blocking loop: Enables cancellation and parallelization without platform-specific code. Callers who want simple blocking behavior pass
Nonefor both start and count. Research showed rust-nostr uses a tight blocking loop with optional multi-threading via feature flags; welshman uses Web Workers. Our approach avoids both by pushing the concurrency concern to the caller. -
No prefix generation:
get_prefixes_for_difficulty()(seen in rust-nostr and nostr-tools) converts bit difficulty to hex prefixes for relay querying. Deferred — it's orthogonal to mining/validation and can be added later if needed. -
Nonce as u64: rust-nostr uses u128, but u64 gives 1.8×10¹⁹ attempts — more than enough for any practical difficulty. Keeps the tag string shorter and matches JS number limits for WASM interop.
-
Clone, don't mutate:
mine_powtakes&OwnedEventand clones internally. The caller keeps their original event unchanged. This matches the functional style of the event pipeline (each step returns a new type). -
check_pow validates both id and tag: Following NIP-13 semantics. The id check proves the work; the tag check proves intent. Both are needed for correct validation.
Open Questions
None — scope is well-defined and all design decisions are resolved.