Files
coracle-rust/book/08-proof-of-work.md
T
Jon Staab c8f6bc1652 Add pow
2026-04-21 06:18:49 -07:00

323 lines
11 KiB
Markdown

# 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", "<counter>",
"<target>"]`. 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", "<counter>", "<difficulty>"]` 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<HashedEvent> {
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<HashedEvent> {
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.