Skip to main content

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}