diff --git a/book/08-proof-of-work.md b/book/08-proof-of-work.md new file mode 100644 index 0000000..257718d --- /dev/null +++ b/book/08-proof-of-work.md @@ -0,0 +1,322 @@ +# Proof of Work + +Every event id is a SHA-256 hash. Most of the time that hash is +whatever it happens to be — nobody cares about the specific bit +pattern. Proof of work changes that. NIP-13 defines a convention where +a client burns CPU time searching for a nonce that makes the event id +start with a target number of leading zero bits. The result is an event +that carries a verifiable proof of computational effort baked into its +identity. + +The purpose is spam resistance. A relay can require that incoming events +have, say, 20 leading zero bits. Generating one costs the publisher +roughly 2^20 hash attempts — a second or two on modern hardware — but +spamming a thousand events costs a thousand times that. It is not a +substitute for moderation, but it raises the floor. + +The protocol mechanism is a single tag: `["nonce", "", +""]`. The first value is the nonce that was found; the second +is the difficulty the miner was aiming for. Both are needed: the nonce +changes the event id (because tags are part of the hash input), and the +target records intent so that a verifier can distinguish a miner who +committed to 20 bits of work from one who committed to 4 and got lucky. + +This chapter builds three things: a function to count leading zero bits, +a function to read the validated proof-of-work difficulty from an event, +and a pair of mining functions that search for a valid nonce and return +a `HashedEvent` with the proof embedded. The mining API is batch-based: +callers control the loop, which keeps the library free of +platform-specific threading or async machinery. + +## The module + +```rust {file=coracle-lib/src/lib.rs} +pub mod pow; +``` + +```rust {file=coracle-lib/src/pow.rs} +//! NIP-13 proof of work: difficulty validation and mining. +//! +//! [`mine_pow`] blocks until a valid nonce is found. [`mine_pow_batch`] +//! tries a bounded range of nonces and returns `None` if the batch is +//! exhausted, giving the caller control over cancellation and +//! parallelization. + +use sha2::{Digest, Sha256}; + +use crate::events::{Event, HashedEvent, OwnedEvent}; +use crate::tags::{Tag, Tags}; +``` + +## Counting leading zero bits + +Everything else in this module builds on one primitive: counting how many +leading bits of a byte slice are zero. A hash with 20 leading zero bits +starts with at least five zero hex characters (20 / 4 = 5). Difficulty +is measured in bits, not hex characters, because bits give finer +granularity — you can require 21 bits of work, not just 20 or 24. + +The algorithm walks through bytes. Each fully-zero byte contributes +eight bits. The first non-zero byte contributes its own leading zeros +(via the `leading_zeros` intrinsic, which compiles to a single CPU +instruction on most architectures), and then we stop — no byte after +that can contribute leading zeros. + +```rust {file=coracle-lib/src/pow.rs} +/// Count the number of leading zero bits in a byte slice. +/// +/// Returns 0 for an empty slice. For a 32-byte SHA-256 hash, the +/// maximum return value is 256. +pub fn get_leading_zero_bits(hash: &[u8]) -> u8 { + let mut count: u8 = 0; + for &byte in hash { + if byte == 0 { + count += 8; + } else { + count += byte.leading_zeros() as u8; + break; + } + } + count +} +``` + +## Validation + +Validating proof of work on an existing event means answering the +question: how much work did this event *commit to*? That's the minimum +of two values — the actual leading zero bits in the hash and the +difficulty claimed in the nonce tag. The hash proves work was done; the +tag proves intent. A miner who targeted difficulty 4 and happened to +land on 20 zero bits only committed to 4 bits of work. + +The core logic takes just an id and tags so it can be shared across +event types. + +```rust {file=coracle-lib/src/pow.rs} +/// Return the validated proof-of-work difficulty for an event id and tags. +/// +/// The result is the minimum of the actual leading zero bits in `id` +/// and the difficulty target claimed in the `nonce` tag. Returns 0 if +/// no nonce tag is present. +pub fn get_pow(id: &[u8; 32], tags: &Tags) -> u8 { + let leading = get_leading_zero_bits(id); + + let claimed: u8 = tags + .find("nonce") + .and_then(|t| t.get(2)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + leading.min(claimed) +} +``` + +The nonce tag's third entry (index 2) holds the difficulty target as a +decimal string. If the tag is absent or the value doesn't parse, the +claimed target is zero, so `get_pow` returns zero — no verifiable +commitment was made. + +### Methods on event types + +Both `HashedEvent` and `Event` carry an id and tags, so both get a +`get_pow` method that delegates to the free function. This lets callers +write `event.get_pow() >= 20` regardless of whether the event has been +signed yet. + +```rust {file=coracle-lib/src/pow.rs} +impl HashedEvent { + /// Return the validated proof-of-work difficulty of this event. + /// + /// The result is the minimum of the actual leading zero bits in the + /// event id and the difficulty claimed in the nonce tag. + pub fn get_pow(&self) -> u8 { + get_pow(&self.id, &self.tags) + } +} + +impl Event { + /// Return the validated proof-of-work difficulty of this event. + /// + /// The result is the minimum of the actual leading zero bits in the + /// event id and the difficulty claimed in the nonce tag. + pub fn get_pow(&self) -> u8 { + get_pow(&self.id, &self.tags) + } +} +``` + +## Mining + +Mining is a brute-force search. For each candidate nonce, the miner +builds a nonce tag, constructs the canonical serialization that the +events chapter defined, hashes it, and checks the leading zero bits. If +the hash meets the target, the search is over; if not, it tries the +next nonce. + +The canonical form is the same JSON array from the events chapter: +`[0, pubkey, created_at, kind, tags, content]`. The nonce tag is +appended to the event's existing tags before serialization, so the +nonce value feeds into the hash. Changing the nonce changes the hash — +that's the whole mechanism. + +Two free functions expose this search at different levels of control, +and `OwnedEvent` gets methods that delegate to them. + +### `mine_pow` — the simple interface + +For callers who just want a result and are willing to block until it +arrives: + +```rust {file=coracle-lib/src/pow.rs} +/// Mine proof of work for an event, blocking until a valid nonce is found. +/// +/// Appends a `["nonce", "", ""]` tag to the event's +/// tags and searches for a nonce that produces an event id with at least +/// `difficulty` leading zero bits. Returns the resulting `HashedEvent`. +/// +/// This function does not return until a solution is found. For +/// cancellation or parallelization, use [`mine_pow_batch`] instead. +pub fn mine_pow(event: &OwnedEvent, difficulty: u8) -> HashedEvent { + let mut start: u64 = 0; + loop { + let batch_size: u64 = 1_000_000; + if let Some(result) = mine_pow_batch(event, difficulty, start, batch_size) { + return result; + } + start += batch_size; + } +} +``` + +### `mine_pow_batch` — the batch interface + +For callers who need control over when to stop or how to distribute +work across threads or web workers: + +```rust {file=coracle-lib/src/pow.rs} +/// Try to mine proof of work over a bounded range of nonces. +/// +/// Searches nonces from `start` to `start + count - 1`. Returns +/// `Some(HashedEvent)` if a nonce producing at least `difficulty` +/// leading zero bits is found, or `None` if the entire range is +/// exhausted without a match. +/// +/// # Parallelization +/// +/// To distribute work across N workers, give each worker a +/// non-overlapping range: worker *i* calls +/// `mine_pow_batch(event, difficulty, i * chunk, chunk)`. +/// The first worker to return `Some` wins; the rest can be cancelled +/// by simply not issuing further batches. +pub fn mine_pow_batch( + event: &OwnedEvent, + difficulty: u8, + start: u64, + count: u64, +) -> Option { + let difficulty_str = difficulty.to_string(); + let mut tags = event.tags.clone(); + tags.0.push(Tag::new("nonce", ["0", &difficulty_str])); + let nonce_idx = tags.0.len() - 1; + + let end = start.saturating_add(count); + for nonce in start..end { + tags.0[nonce_idx].0[1] = nonce.to_string(); + + let canonical = serde_json::json!([ + 0, + event.pubkey.to_hex(), + event.created_at, + event.kind, + &tags, + &event.content, + ]) + .to_string(); + + let id: [u8; 32] = Sha256::digest(canonical.as_bytes()).into(); + + if get_leading_zero_bits(&id) >= difficulty { + return Some(HashedEvent { + content: event.content.clone(), + kind: event.kind, + tags, + created_at: event.created_at, + pubkey: event.pubkey, + id, + }); + } + } + + None +} +``` + +The batch function clones the event's tags once, appends a placeholder +nonce tag, then mutates the nonce value in place on each iteration. +The canonical JSON is rebuilt every iteration — the nonce string changes +each time, so the serialization must too — but the tag vector itself is +reused rather than reallocated. + +The returned `HashedEvent` includes the winning nonce tag in its tags. +From there it slots into the normal event pipeline: call `.sign()` to +produce a full `Event` ready for the wire. + +### Methods on `OwnedEvent` + +For callers who prefer the method style, `OwnedEvent` delegates to the +free functions: + +```rust {file=coracle-lib/src/pow.rs} +impl OwnedEvent { + /// Mine proof of work, blocking until a valid nonce is found. + /// + /// See [`mine_pow`] for details. + pub fn mine_pow(&self, difficulty: u8) -> HashedEvent { + mine_pow(self, difficulty) + } + + /// Try to mine proof of work over a bounded range of nonces. + /// + /// See [`mine_pow_batch`] for details. + pub fn mine_pow_batch( + &self, + difficulty: u8, + start: u64, + count: u64, + ) -> Option { + mine_pow_batch(self, difficulty, start, count) + } +} +``` + +## Usage patterns + +The split between `mine_pow` and `mine_pow_batch` keeps the library +code simple while supporting a range of caller strategies: + +**Blocking single-threaded** — the common case. `event.mine_pow(20)` +blocks until it finds a valid nonce and returns the `HashedEvent`. + +**Batched with cancellation** — a UI that wants to let the user abort. +The caller loops over `event.mine_pow_batch(20, cursor, 100_000)`, +incrementing `cursor` by 100,000 each round. Between rounds it checks +whether the user pressed cancel. + +**Parallel native threads** — each of N threads gets a non-overlapping +chunk of the nonce space. The first thread to return `Some` wins; the +rest are abandoned. + +**WASM web workers** — the main thread posts a batch range to a worker +via `postMessage`. The worker calls `mine_pow_batch`, posts the result +back. To cancel, the main thread simply stops posting new batches. No +shared memory, no atomics, no platform-specific API in the library. + +**Validation** — a relay checking incoming events writes +`event.get_pow() >= 20`. The method works on both `HashedEvent` and +`Event`. + +## What's next + +The next chapter introduces filters — the query language clients use to +ask relays for events matching a set of criteria. diff --git a/book/plan/proof-of-work.md b/book/plan/proof-of-work.md new file mode 100644 index 0000000..f5b1666 --- /dev/null +++ b/book/plan/proof-of-work.md @@ -0,0 +1,125 @@ +# 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 + +1. **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. + +2. **Module setup** — Add `pub mod pow;` to `coracle-lib/src/lib.rs`. Module imports. + +3. **Counting leading zero bits** — `get_leading_zero_bits(hash: &[u8]) -> u8`. Pure utility + that iterates bytes: full zero byte = +8 bits, partial byte uses `leading_zeros()`. This + is the foundation everything else builds on. + +4. **The nonce tag** — NIP-13 specifies a `["nonce", "", ""]` 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 existing `Tags` type. + +5. **Validation** — `check_pow(event: &HashedEvent, difficulty: u8) -> bool`. Checks that the + event id has at least `difficulty` leading zero bits AND that the nonce tag's claimed + difficulty is at least `difficulty`. Both checks are needed: the id check proves the work + was done, the tag check proves the miner intended at least this difficulty. + +6. **Mining** — `mine_pow(event: &OwnedEvent, difficulty: u8, start: Option, count: Option) -> Option`. + Clones the event's tags, appends/updates a nonce tag, hashes, checks difficulty. Iterates + nonces from `start` (default 0) for up to `count` attempts (default unbounded). Returns + `Some(HashedEvent)` on success, `None` if the batch is exhausted. + +7. **Usage patterns** — Brief prose (not tangled code) showing: + - Simple: `mine_pow(&event, 20, None, None)` — blocks until done + - Batched: loop calling with `Some(cursor)` and `Some(100_000)` + - Parallel: N threads/workers with non-overlapping ranges + - WASM: worker runs batches, main thread stops sending to cancel + +8. **What's next** — Transition to filters chapter. + +## API Design + +```rust +/// 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, + count: Option, +) -> Option +``` + +## 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`, `HashedEvent` from `events`, `Tags` from `tags` +- Add `pub mod pow;` to `coracle-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 + found` function 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 + +1. **Batch-based API over blocking loop**: Enables cancellation and parallelization without + platform-specific code. Callers who want simple blocking behavior pass `None` for 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. + +2. **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. + +3. **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. + +4. **Clone, don't mutate**: `mine_pow` takes `&OwnedEvent` and clones internally. The caller + keeps their original event unchanged. This matches the functional style of the event + pipeline (each step returns a new type). + +5. **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. diff --git a/book/research/proof-of-work.md b/book/research/proof-of-work.md new file mode 100644 index 0000000..c9a6629 --- /dev/null +++ b/book/research/proof-of-work.md @@ -0,0 +1,195 @@ +# 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 using `Math.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/hashes` for SHA-256 + - Mutates `created_at` when time rolls over + - Returns event with computed `id` field + +**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 utilities +- `crates/nostr/src/event/builder.rs` — Mining loop +- `crates/nostr/src/event/id.rs` — Validation +- `crates/nostr/src/event/tag/standard.rs` — POW tag structure + +**Data Structures:** +```rust +TagStandard::POW { nonce: u128, difficulty: u8 } +// Serializes to: ["nonce", "", ""] + +EventBuilder { pow: Option, ... } +``` + +**Core Algorithm — `get_leading_zero_bits>(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`:** +- 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 `UnsignedEvent` on 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` with `Ordering::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:** +```rust +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 space +- `difficulty: u8` — matches SHA-256 bit width +- Feature-gated multi-threading keeps `no_std` compatibility +- 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 }` + - Creates Web Worker from inline `Blob` URL + - 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 `start` and `step` parameters for distributing across multiple workers + +- **`getPow(event: HashedEvent): number`** — Validates difficulty from event hash. Counts leading zero bytes, then uses `Math.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 + +1. **Difficulty metric:** All implementations use leading zero bits in SHA-256 hash (NIP-13 standard). Not byte-aligned — allows fine-grained difficulty. + +2. **Tag format:** Universal `["nonce", "", ""]` tag. + +3. **Mining loop:** Increment nonce, recompute hash, check leading zeros. Simple brute force — no shortcuts possible. + +4. **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. + +5. **Leading zero counting:** Two approaches: + - Byte iteration + CPU intrinsic (`leading_zeros()` / `Math.clz32()`) + - Hex string nibble iteration (nostr-tools) + +6. **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::spawn` or `tokio::spawn_blocking` +- WASM: caller runs in a Web Worker + +### Validation + +Two levels: +1. `get_leading_zero_bits(hash: &[u8]) -> u8` — pure utility +2. `check_pow(event: &HashedEvent, difficulty: u8) -> bool` — checks event's nonce tag difficulty matches actual hash difficulty + +### Querying + +`get_prefixes_for_difficulty(difficulty: u8) -> Vec` 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 +- `u128` nonce type is standard Rust + +### Threading Considerations + +Rather than building threading into the mining function: +- Keep `mine_pow(event: OwnedEvent, difficulty: u8) -> HashedEvent` as 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: +```rust +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` diff --git a/coracle-lib/tests/pow.rs b/coracle-lib/tests/pow.rs new file mode 100644 index 0000000..d8f4014 --- /dev/null +++ b/coracle-lib/tests/pow.rs @@ -0,0 +1,217 @@ +use coracle_lib::events::{EventContent, OwnedEvent}; +use coracle_lib::keys::SecretKey; +use coracle_lib::pow::{get_leading_zero_bits, get_pow, mine_pow, mine_pow_batch}; +use coracle_lib::tags::{Tag, Tags}; + +fn fixed_secret() -> SecretKey { + let bytes: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + ]; + SecretKey::from_hex(&hex::encode(bytes)).unwrap() +} + +fn test_event() -> OwnedEvent { + EventContent::new("hello nostr", Tags::new()) + .kind(1) + .stamp(1_700_000_000) + .own(fixed_secret().public_key()) +} + +// --- get_leading_zero_bits --- + +#[test] +fn leading_zeros_empty_slice() { + assert_eq!(get_leading_zero_bits(&[]), 0); +} + +#[test] +fn leading_zeros_all_zeros() { + assert_eq!(get_leading_zero_bits(&[0, 0, 0, 0]), 32); +} + +#[test] +fn leading_zeros_first_byte_nonzero() { + assert_eq!(get_leading_zero_bits(&[0x80, 0x00]), 0); + assert_eq!(get_leading_zero_bits(&[0x01, 0x00]), 7); + assert_eq!(get_leading_zero_bits(&[0x0F]), 4); +} + +#[test] +fn leading_zeros_with_zero_prefix() { + assert_eq!(get_leading_zero_bits(&[0x00, 0x01]), 15); + assert_eq!(get_leading_zero_bits(&[0x00, 0x00, 0x80]), 16); + assert_eq!(get_leading_zero_bits(&[0x00, 0x00, 0x00, 0x01]), 31); +} + +#[test] +fn leading_zeros_known_hash() { + // "000006d8..." → 0x00, 0x00, 0x06 → 16 + 5 = 21 + let hash = hex::decode("000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358") + .unwrap(); + assert_eq!(get_leading_zero_bits(&hash), 21); +} + +// --- get_pow (free function) --- + +#[test] +fn get_pow_returns_min_of_hash_and_claim() { + let event = test_event(); + let hashed = mine_pow(&event, 8); + // The free function should return at least 8 + assert!(get_pow(&hashed.id, &hashed.tags) >= 8); +} + +#[test] +fn get_pow_zero_without_nonce_tag() { + let hashed = test_event().hash(); + assert_eq!(get_pow(&hashed.id, &hashed.tags), 0); +} + +#[test] +fn get_pow_capped_by_claimed_difficulty() { + // Mine at difficulty 4 — even if hash has more leading zeros, + // get_pow returns at most 4 + let event = test_event(); + let hashed = mine_pow(&event, 4); + assert!(get_pow(&hashed.id, &hashed.tags) >= 4); + // Actual leading zeros might exceed 4, but get_pow is min(leading, claimed) + assert_eq!(get_pow(&hashed.id, &hashed.tags), 4); +} + +// --- HashedEvent::get_pow method --- + +#[test] +fn hashed_event_get_pow_method() { + let event = test_event(); + let hashed = mine_pow(&event, 8); + assert!(hashed.get_pow() >= 8); +} + +#[test] +fn hashed_event_get_pow_zero_without_nonce() { + let hashed = test_event().hash(); + assert_eq!(hashed.get_pow(), 0); +} + +// --- Event::get_pow method --- + +#[test] +fn signed_event_get_pow_method() { + let sk = fixed_secret(); + let event = test_event(); + let hashed = mine_pow(&event, 8); + let sig = sk.sign(&hashed.id); + let signed = hashed.sign(sig); + assert!(signed.get_pow() >= 8); +} + +// --- OwnedEvent::mine_pow method --- + +#[test] +fn owned_event_mine_pow_method() { + let event = test_event(); + let result = event.mine_pow(8); + assert!(result.get_pow() >= 8); +} + +// --- OwnedEvent::mine_pow_batch method --- + +#[test] +fn owned_event_mine_pow_batch_method() { + let event = test_event(); + let result = event.mine_pow_batch(1, 0, 1_000_000); + assert!(result.is_some()); + assert!(result.unwrap().get_pow() >= 1); +} + +// --- mine_pow (free function) --- + +#[test] +fn mine_pow_returns_valid_result() { + let event = test_event(); + let result = mine_pow(&event, 8); + + assert!(get_leading_zero_bits(&result.id) >= 8); + + let nonce_tag = result.tags.find("nonce").expect("should have nonce tag"); + assert_eq!(nonce_tag.get(2), Some("8")); + + let _nonce: u64 = nonce_tag.value().parse().expect("nonce should be a number"); +} + +#[test] +fn mine_pow_preserves_event_fields() { + let event = test_event(); + let result = mine_pow(&event, 4); + + assert_eq!(result.content, event.content); + assert_eq!(result.kind, event.kind); + assert_eq!(result.created_at, event.created_at); + assert_eq!(result.pubkey, event.pubkey); +} + +#[test] +fn mine_pow_preserves_existing_tags() { + let mut event = test_event(); + event.tags.0.push(Tag::new("t", ["nostr"])); + let result = mine_pow(&event, 4); + + assert!(result.tags.find("t").is_some()); + assert!(result.tags.find("nonce").is_some()); +} + +#[test] +fn mine_pow_does_not_mutate_input() { + let event = test_event(); + let tags_before = event.tags.clone(); + let _result = mine_pow(&event, 4); + assert_eq!(event.tags, tags_before); +} + +// --- mine_pow_batch (free function) --- + +#[test] +fn mine_pow_batch_returns_none_when_exhausted() { + let event = test_event(); + let result = mine_pow_batch(&event, 64, 0, 10); + assert!(result.is_none()); +} + +#[test] +fn mine_pow_batch_finds_solution_in_range() { + let event = test_event(); + let result = mine_pow_batch(&event, 1, 0, 1_000_000); + assert!(result.is_some()); + assert!(get_leading_zero_bits(&result.unwrap().id) >= 1); +} + +#[test] +fn mine_pow_batch_respects_start_offset() { + let event = test_event(); + let a = mine_pow_batch(&event, 4, 0, 1_000_000); + let b = mine_pow_batch(&event, 4, 500_000, 1_000_000); + + assert!(a.is_some()); + assert!(b.is_some()); + + let nonce_a = a.unwrap().tags.find("nonce").unwrap().value().to_string(); + let nonce_b = b.unwrap().tags.find("nonce").unwrap().value().to_string(); + assert_ne!(nonce_a, nonce_b); +} + +// --- Integration: mine then sign then verify --- + +#[test] +fn mined_event_can_be_signed_and_verified() { + let sk = fixed_secret(); + let event = test_event(); + let hashed = mine_pow(&event, 8); + + let sig = sk.sign(&hashed.id); + let signed = hashed.sign(sig); + + assert!(signed.verify_id()); + signed.verify().expect("mined and signed event should verify"); + assert!(signed.get_pow() >= 8); +} diff --git a/coracle-wasm/src/lib.rs b/coracle-wasm/src/lib.rs index ab510f8..f36cd3b 100644 --- a/coracle-wasm/src/lib.rs +++ b/coracle-wasm/src/lib.rs @@ -8,7 +8,7 @@ use wasm_bindgen::prelude::*; use coracle_lib::encryption as ce; -use coracle_lib::event as cev; +use coracle_lib::events as cev; use coracle_lib::keys as ck; fn js_err(e: E) -> JsError { @@ -181,16 +181,24 @@ impl Event { created_at: u64, sk: &SecretKey, ) -> Result { - let tags: Vec> = + use coracle_lib::tags::{Tag, Tags}; + + let raw_tags: Vec> = serde_wasm_bindgen::from_value(tags).map_err(|e| JsError::new(&e.to_string()))?; - // coracle-lib's SecretKey hides the inner secp type behind a - // crate-private accessor, so round-trip through hex to hand - // Event::new the low-level key it wants. - let bytes = hex::decode(sk.0.to_hex()).map_err(js_err)?; - let secp_sk = secp256k1::SecretKey::from_slice(&bytes).map_err(js_err)?; - Ok(Event(cev::Event::new( - kind, content, tags, created_at, &secp_sk, - ))) + let tags = Tags::from( + raw_tags + .into_iter() + .map(|v| Tag::from(v)) + .collect::>(), + ); + + let hashed = cev::EventContent::new(content, tags) + .kind(kind) + .stamp(created_at) + .own(sk.0.public_key()) + .hash(); + let sig = sk.0.sign(&hashed.id); + Ok(Event(hashed.sign(sig))) } /// Parse an event from its JSON representation. @@ -209,12 +217,12 @@ impl Event { #[wasm_bindgen(getter)] pub fn id(&self) -> String { - self.0.id.clone() + hex::encode(self.0.id) } #[wasm_bindgen(getter)] pub fn pubkey(&self) -> String { - self.0.pubkey.clone() + self.0.pubkey.to_hex() } #[wasm_bindgen(getter, js_name = createdAt)] @@ -234,7 +242,7 @@ impl Event { #[wasm_bindgen(getter)] pub fn sig(&self) -> String { - self.0.sig.clone() + hex::encode(self.0.sig) } #[wasm_bindgen(getter)] @@ -242,24 +250,15 @@ impl Event { serde_wasm_bindgen::to_value(&self.0.tags).map_err(|e| JsError::new(&e.to_string())) } - /// Canonical `[0, pubkey, created_at, kind, tags, content]` serialization. - #[wasm_bindgen] - pub fn serialize(&self) -> String { - self.0.serialize() - } - - #[wasm_bindgen(js_name = computeId)] - pub fn compute_id(&self) -> String { - self.0.compute_id() - } - - #[wasm_bindgen(js_name = idIsValid)] - pub fn id_is_valid(&self) -> bool { - self.0.id_is_valid() - } - + /// Verify both the id and the Schnorr signature. #[wasm_bindgen] pub fn verify(&self) -> bool { - self.0.verify() + self.0.verify().is_ok() + } + + /// Check whether the recomputed id matches the stored id. + #[wasm_bindgen(js_name = verifyId)] + pub fn verify_id(&self) -> bool { + self.0.verify_id() } }