nula_core/nips/nip13.rs
1//! [NIP-13] Proof of Work.
2//!
3//! NIP-13 mines an event so that its [`EventId`] has at least `D` leading
4//! zero bits. The author commits to the targeted difficulty via the
5//! `["nonce", "<nonce>", "<committed-difficulty>"]` tag; relays/clients can
6//! reject events whose actual or committed difficulty is below their policy.
7//!
8//! This module provides three layers:
9//!
10//! - [`count_leading_zero_bits`] / [`event_id_difficulty`] — pure helpers.
11//! - [`verify_pow`] — full NIP-13 validation including committed
12//! difficulty.
13//! - [`mine`] / [`mine_and_sign`] — blocking miners that brute-force a
14//! nonce until the event id satisfies `D`.
15//!
16//! The miners are intentionally synchronous; offload them to a worker pool
17//! when integrating into an interactive client.
18//!
19//! [NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md
20//! [`EventId`]: crate::EventId
21
22use thiserror::Error;
23
24use crate::event::{Event, EventBuilder, EventBuilderError, EventId, Tag, TagKind, Tags};
25use crate::key::{Keys, PublicKey};
26use crate::types::{Timestamp, TimestampError};
27
28/// Wire name of the NIP-13 nonce tag (`nonce`).
29pub const NONCE_TAG: &str = "nonce";
30
31/// Errors raised by [`verify_pow`] and [`verify_pow_strict`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
33#[non_exhaustive]
34pub enum PowError {
35 /// The event's id has fewer leading zero bits than required.
36 #[error("event id has {actual} leading zero bits, need {expected}")]
37 InsufficientWork {
38 /// Bits the id actually starts with.
39 actual: u8,
40 /// Minimum required by the caller.
41 expected: u8,
42 },
43 /// The author's committed difficulty (the third element of the `nonce`
44 /// tag) is below what the caller demands. NIP-13 §commitment requires
45 /// the commitment to match or exceed the verifier's threshold so that
46 /// random low-difficulty matches cannot be passed off as `PoW`.
47 #[error("committed difficulty {actual} < {expected}")]
48 InsufficientCommitment {
49 /// Difficulty advertised by the author.
50 actual: u8,
51 /// Minimum required by the caller.
52 expected: u8,
53 },
54 /// The `nonce` tag carried a non-integer commitment that we could not
55 /// parse.
56 #[error("nonce tag commitment is not a valid u8 integer")]
57 InvalidCommitment,
58 /// The strict-mode verifier required a committed difficulty but the
59 /// event carried no `nonce` tag (or the tag had no commitment column).
60 #[error("strict PoW verification requires a committed difficulty")]
61 MissingCommitment,
62}
63
64/// Errors raised by [`mine`] / [`mine_and_sign`].
65#[derive(Debug, Error)]
66#[non_exhaustive]
67pub enum MineError {
68 /// The wall clock could not be read.
69 #[error(transparent)]
70 Clock(#[from] TimestampError),
71 /// Forwarded from event signing.
72 #[error(transparent)]
73 Builder(#[from] EventBuilderError),
74 /// The nonce search exhausted the full `u64` space without finding a
75 /// matching id at the requested difficulty.
76 ///
77 /// In practice unreachable for any real-world `difficulty`; the variant
78 /// exists so the mining loop never silently saturates and never spins
79 /// forever. Callers may recover by re-mining with a different
80 /// `created_at` (NIP-13 explicitly suggests refreshing it).
81 #[error("nonce search exhausted u64 space; refresh created_at and retry")]
82 NonceExhausted,
83}
84
85/// Count the leading zero bits in a byte slice.
86///
87/// `[0xff, …]` returns `0`; `[0x00, 0xff, …]` returns `8`; `[0x00, 0x00, …]`
88/// returns at least `16`. Empty slices return `0`.
89#[must_use]
90pub fn count_leading_zero_bits(bytes: &[u8]) -> u8 {
91 let mut total: u8 = 0;
92 for &b in bytes {
93 if b == 0 {
94 total = total.saturating_add(8);
95 } else {
96 // `b.leading_zeros()` returns a value in `0..=8`, which fits in
97 // `u8`; `try_from` makes the narrowing explicit for clippy and
98 // gives us a sound `0` fallback that this branch never reaches.
99 let z = u8::try_from(b.leading_zeros()).unwrap_or(0);
100 return total.saturating_add(z);
101 }
102 }
103 total
104}
105
106/// Number of leading zero bits in `id`.
107#[must_use]
108pub fn event_id_difficulty(id: &EventId) -> u8 {
109 count_leading_zero_bits(&id.to_byte_array())
110}
111
112/// Read the committed difficulty (third element of the `nonce` tag), if
113/// present and well formed.
114///
115/// Returns `Some(d)` when the tag exists and parses as `u8`. Returns
116/// `None` when the tag is missing or has fewer than 3 elements. Returns an
117/// `Err` when the third element is present but unparseable.
118///
119/// # Errors
120///
121/// Returns [`PowError::InvalidCommitment`] when the third element exists
122/// but is not a non-negative integer that fits in `u8`.
123pub fn committed_difficulty(event: &Event) -> Result<Option<u8>, PowError> {
124 let kind = TagKind::from_wire(NONCE_TAG);
125 let Some(tag) = event.tags.find_first(&kind) else {
126 return Ok(None);
127 };
128 let Some(commitment) = tag.values().get(2) else {
129 return Ok(None);
130 };
131 commitment
132 .parse::<u8>()
133 .map(Some)
134 .map_err(|_| PowError::InvalidCommitment)
135}
136
137/// Verify that `event` satisfies `min_difficulty` according to NIP-13.
138///
139/// Specifically, the event's id must have at least `min_difficulty` leading
140/// zero bits, and — if the author included a `nonce` commitment — that
141/// commitment must also be at least `min_difficulty`.
142///
143/// `min_difficulty == 0` accepts every event.
144///
145/// # Errors
146///
147/// Returns [`PowError::InsufficientWork`] when the id is below the bar,
148/// [`PowError::InsufficientCommitment`] when the commitment falls short,
149/// or [`PowError::InvalidCommitment`] when the commitment is malformed.
150pub fn verify_pow(event: &Event, min_difficulty: u8) -> Result<(), PowError> {
151 let actual = event_id_difficulty(&event.id);
152 if actual < min_difficulty {
153 return Err(PowError::InsufficientWork {
154 actual,
155 expected: min_difficulty,
156 });
157 }
158 if let Some(commitment) = committed_difficulty(event)?
159 && commitment < min_difficulty
160 {
161 return Err(PowError::InsufficientCommitment {
162 actual: commitment,
163 expected: min_difficulty,
164 });
165 }
166 Ok(())
167}
168
169/// Strict version of [`verify_pow`]: also rejects events that lack a
170/// committed difficulty entirely.
171///
172/// NIP-13 §commitment notes that "without a committed target difficulty
173/// you could not reject" a low-difficulty grind that happened to land on
174/// a high zero-bit count. Strict verifiers (relays enforcing `PoW`
175/// policies) should call this entry point so an absent commitment is
176/// treated as a policy violation rather than silently accepted.
177///
178/// # Errors
179///
180/// Returns the matching [`PowError`] variant; in particular,
181/// [`PowError::MissingCommitment`] when the event has no usable `nonce`
182/// commitment column.
183pub fn verify_pow_strict(event: &Event, min_difficulty: u8) -> Result<(), PowError> {
184 verify_pow(event, min_difficulty)?;
185 if min_difficulty > 0 && committed_difficulty(event)?.is_none() {
186 return Err(PowError::MissingCommitment);
187 }
188 Ok(())
189}
190
191/// Mine a [`PowAttempt`] until the event id has `difficulty` leading zero
192/// bits. Returns the unsigned, mined attempt; the caller signs it.
193///
194/// # Errors
195///
196/// Returns [`MineError::Clock`] if the wall clock cannot be read while
197/// fixing `created_at`.
198pub fn mine(
199 builder: &EventBuilder,
200 pubkey: PublicKey,
201 difficulty: u8,
202) -> Result<PowAttempt, MineError> {
203 PowAttempt::mine(builder, pubkey, difficulty)
204}
205
206/// Mine a NIP-13 `PoW` for `builder` and sign it with `keys`.
207///
208/// `keys` must own the public key that will appear on the event; this is
209/// the same constraint [`crate::UnsignedEvent::sign_with_keys`] enforces.
210///
211/// # Errors
212///
213/// Returns [`MineError::Clock`] if the system clock cannot be read or
214/// [`MineError::Builder`] if the signer rejects the unsigned event.
215pub fn mine_and_sign(
216 builder: &EventBuilder,
217 keys: &Keys,
218 difficulty: u8,
219) -> Result<Event, MineError> {
220 let attempt = PowAttempt::mine(builder, *keys.public_key(), difficulty)?;
221 Ok(attempt.into_signed_with_keys(keys)?)
222}
223
224/// Outcome of a successful mining run.
225#[derive(Debug, Clone)]
226pub struct PowAttempt {
227 /// The mined unsigned event.
228 pub unsigned: crate::event::UnsignedEvent,
229 /// Number of nonces tried (counts the winning attempt as `1`).
230 pub iterations: u64,
231 /// Difficulty the miner targeted (also the commitment).
232 pub difficulty: u8,
233}
234
235impl PowAttempt {
236 /// Run the mining loop synchronously until the id has `difficulty`
237 /// leading zero bits.
238 pub(crate) fn mine(
239 builder: &EventBuilder,
240 pubkey: PublicKey,
241 difficulty: u8,
242 ) -> Result<Self, MineError> {
243 let created_at = match builder.current_created_at() {
244 Some(ts) => ts,
245 None => Timestamp::now()?,
246 };
247 let kind = builder.current_kind();
248 let content = builder.current_content().to_owned();
249 let nonce_kind = TagKind::from_wire(NONCE_TAG);
250 // Snapshot the user-supplied tags once, dropping any prior nonce
251 // tag (the miner owns that slot).
252 let prefix: Vec<Tag> = builder
253 .current_tags()
254 .iter()
255 .filter(|t| t.kind() != nonce_kind)
256 .cloned()
257 .collect();
258
259 let mut iterations: u64 = 0;
260 loop {
261 iterations = iterations.checked_add(1).ok_or(MineError::NonceExhausted)?;
262 let mut tags = prefix.clone();
263 tags.push(make_nonce_tag(iterations, difficulty));
264 let unsigned = crate::event::UnsignedEvent::new(
265 pubkey,
266 created_at,
267 kind,
268 Tags::from_vec(tags),
269 content.clone(),
270 );
271 if event_id_difficulty(&unsigned.id) >= difficulty {
272 return Ok(Self {
273 unsigned,
274 iterations,
275 difficulty,
276 });
277 }
278 }
279 }
280
281 /// Sign the mined event with `keys`.
282 ///
283 /// # Errors
284 ///
285 /// Returns [`EventBuilderError::Signer`] if the signer rejects the
286 /// event.
287 pub fn into_signed_with_keys(self, keys: &Keys) -> Result<Event, EventBuilderError> {
288 Ok(self.unsigned.sign_with_keys(keys)?)
289 }
290}
291
292fn make_nonce_tag(nonce: u64, difficulty: u8) -> Tag {
293 Tag::with(
294 &TagKind::from_wire(NONCE_TAG),
295 [nonce.to_string(), difficulty.to_string()],
296 )
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::Kind;
303
304 fn keys() -> Keys {
305 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
306 }
307
308 #[test]
309 fn count_zero_bits_examples() {
310 assert_eq!(count_leading_zero_bits(&[]), 0);
311 assert_eq!(count_leading_zero_bits(&[0xff]), 0);
312 assert_eq!(count_leading_zero_bits(&[0x80]), 0);
313 assert_eq!(count_leading_zero_bits(&[0x40]), 1);
314 assert_eq!(count_leading_zero_bits(&[0x01]), 7);
315 assert_eq!(count_leading_zero_bits(&[0x00, 0xff]), 8);
316 assert_eq!(count_leading_zero_bits(&[0x00, 0x80]), 8);
317 assert_eq!(count_leading_zero_bits(&[0x00, 0x00, 0x10]), 19);
318 assert_eq!(count_leading_zero_bits(&[0x00; 4]), 32);
319 }
320
321 #[test]
322 fn mine_low_difficulty() {
323 let builder = EventBuilder::text_note("hello").created_at(Timestamp::from_secs(1));
324 let event = mine_and_sign(&builder, &keys(), 8).unwrap();
325 assert_eq!(event.kind, Kind::TEXT_NOTE);
326 verify_pow(&event, 8).unwrap();
327 event.verify().unwrap();
328 }
329
330 #[test]
331 fn mine_writes_nonce_commitment() {
332 let builder = EventBuilder::text_note("commit").created_at(Timestamp::from_secs(2));
333 let event = mine_and_sign(&builder, &keys(), 6).unwrap();
334 let commitment = committed_difficulty(&event).unwrap();
335 assert_eq!(commitment, Some(6));
336 }
337
338 #[test]
339 fn mine_replaces_existing_nonce_tag() {
340 let builder = EventBuilder::text_note("ignore-me")
341 .created_at(Timestamp::from_secs(3))
342 .tag(Tag::new(["nonce", "0", "0"]).unwrap());
343 let event = mine_and_sign(&builder, &keys(), 4).unwrap();
344 // Exactly one nonce tag should remain — the one written by the miner.
345 let count = event
346 .tags
347 .iter()
348 .filter(|t| t.kind() == TagKind::from_wire(NONCE_TAG))
349 .count();
350 assert_eq!(count, 1);
351 }
352
353 #[test]
354 fn verify_pow_rejects_low_id_difficulty() {
355 let event = EventBuilder::text_note("no-pow")
356 .created_at(Timestamp::from_secs(4))
357 .sign_with_keys(&keys())
358 .unwrap();
359 // Difficulty 32 is virtually impossible without mining.
360 let err = verify_pow(&event, 32).unwrap_err();
361 assert!(matches!(err, PowError::InsufficientWork { .. }));
362 }
363
364 #[test]
365 fn verify_pow_rejects_low_commitment() {
366 // Mine to 8, then verify against 16: the id may or may not have 16
367 // leading zeros, but the commitment is definitely 8 < 16, so the
368 // commitment check must fire (or the id check first if luck wins).
369 let builder = EventBuilder::text_note("commit-fail").created_at(Timestamp::from_secs(5));
370 let event = mine_and_sign(&builder, &keys(), 8).unwrap();
371 let err = verify_pow(&event, 16).unwrap_err();
372 assert!(matches!(
373 err,
374 PowError::InsufficientWork { .. } | PowError::InsufficientCommitment { actual: 8, .. }
375 ));
376 }
377
378 #[test]
379 fn verify_pow_zero_difficulty_accepts_anything() {
380 let event = EventBuilder::text_note("anything")
381 .created_at(Timestamp::from_secs(6))
382 .sign_with_keys(&keys())
383 .unwrap();
384 verify_pow(&event, 0).unwrap();
385 }
386
387 #[test]
388 fn invalid_commitment_is_reported() {
389 let event = EventBuilder::text_note("bad-commit")
390 .created_at(Timestamp::from_secs(7))
391 .tag(Tag::new(["nonce", "1", "abc"]).unwrap())
392 .sign_with_keys(&keys())
393 .unwrap();
394 let err = committed_difficulty(&event).unwrap_err();
395 assert!(matches!(err, PowError::InvalidCommitment));
396 }
397
398 #[test]
399 fn verify_pow_strict_rejects_missing_commitment() {
400 // Plain text note, no nonce tag -> verify_pow accepts at any
401 // difficulty if the id happens to satisfy it; verify_pow_strict
402 // must reject for any min_difficulty > 0.
403 let event = EventBuilder::text_note("no-nonce")
404 .created_at(Timestamp::from_secs(8))
405 .sign_with_keys(&keys())
406 .unwrap();
407 let err = verify_pow_strict(&event, 1).unwrap_err();
408 assert!(matches!(err, PowError::MissingCommitment));
409 // min_difficulty == 0 means "no PoW required" so strict mode
410 // accepts even unmined events for parity with verify_pow.
411 verify_pow_strict(&event, 0).unwrap();
412 }
413
414 #[test]
415 fn verify_pow_strict_accepts_when_commitment_meets_floor() {
416 let builder = EventBuilder::text_note("strict-ok").created_at(Timestamp::from_secs(9));
417 let event = mine_and_sign(&builder, &keys(), 6).unwrap();
418 verify_pow_strict(&event, 6).unwrap();
419 // Asking for 7 still rejects via the existing commitment check.
420 let err = verify_pow_strict(&event, 7).unwrap_err();
421 assert!(matches!(
422 err,
423 PowError::InsufficientWork { .. } | PowError::InsufficientCommitment { actual: 6, .. }
424 ));
425 }
426
427 /// NIP-13 §"Example mined note" reference vector. The leading bytes of
428 /// the published id (`000006d8…`) encode 5 nibbles of zeroes plus the
429 /// upper bit of `6 = 0b0110`, for a total of 21 leading zero bits. The
430 /// author's nonce tag commits to difficulty 20.
431 ///
432 /// This regression test exercises every public verification path so
433 /// the spec example would catch any drift in `count_leading_zero_bits`,
434 /// `event_id_difficulty`, `committed_difficulty`, or the commitment vs.
435 /// id-difficulty interplay inside `verify_pow`.
436 #[test]
437 fn nip13_spec_example_difficulty_and_commitment() {
438 let id_hex = "000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358";
439 let id = id_hex.parse::<EventId>().unwrap();
440
441 // The spec calls out 21 leading zero bits for this id.
442 assert_eq!(event_id_difficulty(&id), 21);
443
444 // Build the synthetic event the spec would have produced. The
445 // signature is a placeholder: verify_pow only inspects `id` and
446 // the `nonce` tag, never the signature.
447 let pubkey =
448 PublicKey::parse("a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243")
449 .unwrap();
450 let event = Event::from_parts(
451 id,
452 pubkey,
453 Timestamp::from_secs(1_651_794_653),
454 Kind::TEXT_NOTE,
455 Tags::from_vec(vec![Tag::new(["nonce", "776797", "20"]).unwrap()]),
456 "It's just me mining my own business".to_owned(),
457 keys().sign_schnorr(&[0u8; 32]),
458 );
459
460 // The committed difficulty advertised by the author is 20.
461 assert_eq!(committed_difficulty(&event).unwrap(), Some(20));
462
463 // verify against ≤ 20 must pass: id has 21 zero bits, commitment is 20.
464 verify_pow(&event, 0).unwrap();
465 verify_pow(&event, 20).unwrap();
466
467 // NIP-13 anti-grinding rule: even though the id happens to satisfy
468 // 21 zero bits, the *committed* difficulty is only 20, so a
469 // verifier asking for 21 must reject with InsufficientCommitment.
470 let commitment_short = verify_pow(&event, 21).unwrap_err();
471 assert!(matches!(
472 commitment_short,
473 PowError::InsufficientCommitment {
474 actual: 20,
475 expected: 21,
476 }
477 ));
478
479 // Asking for 22 hits the id-difficulty check first.
480 let id_short = verify_pow(&event, 22).unwrap_err();
481 assert!(matches!(
482 id_short,
483 PowError::InsufficientWork {
484 actual: 21,
485 expected: 22,
486 }
487 ));
488 }
489}