Skip to main content

nula_core/nips/
nip45.rs

1//! [NIP-45] Event Counts (`COUNT` verb).
2//!
3//! Adds a relay-side `COUNT` verb that mirrors `REQ` filters but
4//! returns just an integer count (optionally with a `HyperLogLog`
5//! sketch). This module surfaces:
6//!
7//! - [`CountRequest`] / [`CountResponse`] — typed wrappers for the
8//!   wire arrays.
9//! - [`HyperLogLog`] — a fixed-256-register sketch with merge and
10//!   estimate helpers.
11//! - [`hll_offset_for_value`] — the deterministic offset rule the
12//!   spec pins for cacheable counts.
13//!
14//! The wire types are plain structs, so callers compose them into
15//! their own JSON arrays (`["COUNT", <sub_id>, <filter>...]` /
16//! `["COUNT", <sub_id>, {"count": ...}]`) without this crate taking a
17//! `serde_json::Value` round-trip.
18//!
19//! [NIP-45]: https://github.com/nostr-protocol/nips/blob/master/45.md
20#![cfg_attr(docsrs, doc(cfg(feature = "nip45")))]
21
22use sha2::{Digest, Sha256};
23use thiserror::Error;
24
25use crate::filter::Filter;
26use crate::message::SubscriptionId;
27use crate::util::hex;
28
29const HLL_REGISTERS: usize = 256;
30const HLL_HEX_LEN: usize = HLL_REGISTERS * 2;
31
32/// `["COUNT", <subscription_id>, <filter>...]` request.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CountRequest {
35    /// Subscription identifier.
36    pub subscription_id: SubscriptionId,
37    /// One or more filters (OR-combined per spec §"Filters and return
38    /// values").
39    pub filters: Vec<Filter>,
40}
41
42/// `["COUNT", <subscription_id>, {"count": ...}]` response.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CountResponse {
45    /// Subscription identifier the count belongs to.
46    pub subscription_id: SubscriptionId,
47    /// Reported count.
48    pub count: u64,
49    /// Whether the count is a probabilistic estimate.
50    pub approximate: bool,
51    /// Optional `HyperLogLog` sketch.
52    pub hll: Option<HyperLogLog>,
53}
54
55/// 256-register `HyperLogLog` sketch as defined by NIP-45.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct HyperLogLog {
58    registers: [u8; HLL_REGISTERS],
59}
60
61#[expect(
62    clippy::cast_possible_truncation,
63    clippy::cast_sign_loss,
64    reason = "saturating cast is intentional and bounded by the branches above"
65)]
66fn f64_to_u64_saturating(value: f64) -> u64 {
67    if value.is_nan() || value <= 0.0 {
68        0
69    } else if value >= u64::MAX as f64 {
70        u64::MAX
71    } else {
72        value as u64
73    }
74}
75
76/// Errors raised by NIP-45 helpers.
77#[derive(Debug, Error)]
78#[non_exhaustive]
79pub enum CountError {
80    /// `hll` hex string was not exactly 512 characters.
81    #[error("hll must be {HLL_HEX_LEN} hex characters, got {0}")]
82    HllLength(usize),
83    /// `hll` hex string failed to decode.
84    #[error("hll hex decode failure: {0}")]
85    HllHex(String),
86}
87
88impl HyperLogLog {
89    /// Construct an empty sketch (all registers zero).
90    #[must_use]
91    pub const fn new() -> Self {
92        Self {
93            registers: [0u8; HLL_REGISTERS],
94        }
95    }
96
97    /// Borrow the underlying register array.
98    #[must_use]
99    pub const fn as_bytes(&self) -> &[u8; HLL_REGISTERS] {
100        &self.registers
101    }
102
103    /// Construct a sketch from a register array.
104    #[must_use]
105    pub const fn from_bytes(registers: &[u8; HLL_REGISTERS]) -> Self {
106        Self {
107            registers: *registers,
108        }
109    }
110
111    /// Render the sketch as a 512-character lowercase hex string.
112    #[must_use]
113    pub fn to_hex(self) -> String {
114        hex::encode(self.registers)
115    }
116
117    /// Parse a 512-character hex sketch.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`CountError::HllLength`] when the input is not 512
122    /// characters or [`CountError::HllHex`] for non-hex input.
123    pub fn from_hex(hex_str: &str) -> Result<Self, CountError> {
124        if hex_str.len() != HLL_HEX_LEN {
125            return Err(CountError::HllLength(hex_str.len()));
126        }
127        let bytes = hex::decode(hex_str).map_err(|e| CountError::HllHex(e.to_string()))?;
128        let registers: [u8; HLL_REGISTERS] = bytes
129            .try_into()
130            .map_err(|v: Vec<u8>| CountError::HllLength(v.len() * 2))?;
131        Ok(Self { registers })
132    }
133
134    /// Apply an event ID / pubkey to the sketch using the supplied
135    /// offset (see [`hll_offset_for_value`]).
136    ///
137    /// Returns `false` when `offset` falls outside the spec's `8..=23`
138    /// range, leaving the sketch untouched.
139    pub fn observe(&mut self, offset: usize, hash: &[u8; 32]) -> bool {
140        if !(8..=23).contains(&offset) {
141            return false;
142        }
143        let Some(&register_byte) = hash.get(offset) else {
144            return false;
145        };
146        let register_index = register_byte as usize;
147        let tail = hash.get(offset + 1..).unwrap_or(&[]);
148        // Count leading zero bits starting at `offset + 1` (within
149        // the remaining bytes). Add 1 per spec.
150        let mut zeros: u8 = 0;
151        for byte in tail {
152            if *byte == 0 {
153                zeros = zeros.saturating_add(8);
154            } else {
155                let leading = u8::try_from(byte.leading_zeros()).unwrap_or(u8::MAX);
156                zeros = zeros.saturating_add(leading);
157                break;
158            }
159        }
160        let value = zeros.saturating_add(1);
161        let Some(slot) = self.registers.get_mut(register_index) else {
162            return false;
163        };
164        if value > *slot {
165            *slot = value;
166        }
167        true
168    }
169
170    /// Merge another sketch into this one, taking the per-register
171    /// max (the spec's recommended client-side combine).
172    pub fn merge(&mut self, other: &Self) {
173        for (a, b) in self.registers.iter_mut().zip(other.registers.iter()) {
174            if *b > *a {
175                *a = *b;
176            }
177        }
178    }
179
180    /// Estimate the cardinality using the standard `HyperLogLog`
181    /// formula with the spec's 256 registers. Saturates to
182    /// [`u64::MAX`] for absurd inputs (e.g. NaNs).
183    #[must_use]
184    pub fn estimate(&self) -> u64 {
185        const M_F64: f64 = HLL_REGISTERS as f64;
186        let alpha_m = 0.7213_f64 / 1.079_f64.mul_add(1.0 / M_F64, 1.0);
187        let mut sum = 0.0_f64;
188        let mut zero_registers = 0_u32;
189        for &r in &self.registers {
190            if r == 0 {
191                zero_registers += 1;
192            }
193            sum += 2.0_f64.powi(-i32::from(r));
194        }
195        let raw = alpha_m * M_F64 * M_F64 / sum;
196        let estimate = if raw <= 2.5 * M_F64 && zero_registers > 0 {
197            M_F64 * (M_F64 / f64::from(zero_registers)).ln()
198        } else {
199            raw
200        };
201        f64_to_u64_saturating(estimate.round())
202    }
203}
204
205impl Default for HyperLogLog {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211/// Compute the spec's deterministic HLL offset for a single tag-value
212/// `value`.
213///
214/// - 64-character hex strings (event ids / pubkeys) are used as-is.
215/// - Coordinates of the form `<kind>:<pubkey>:<d>` use the `<pubkey>`
216///   half.
217/// - Anything else is SHA-256 hashed.
218///
219/// The 33rd hex character (index 32) is read as a base-16 digit and
220/// `8` is added — yielding an offset in the inclusive range `8..=23`.
221#[must_use]
222pub fn hll_offset_for_value(value: &str) -> usize {
223    let hex_str = canonical_hex_for_value(value);
224    let nibble = hex_str
225        .as_bytes()
226        .get(32)
227        .copied()
228        .map_or(0u8, hex_digit_value);
229    8 + nibble as usize
230}
231
232fn canonical_hex_for_value(value: &str) -> String {
233    if is_64_hex(value) {
234        return value.to_ascii_lowercase();
235    }
236    if let Some(pubkey) = coordinate_pubkey_part(value)
237        && is_64_hex(pubkey)
238    {
239        return pubkey.to_ascii_lowercase();
240    }
241    let mut hasher = Sha256::new();
242    hasher.update(value.as_bytes());
243    let digest = hasher.finalize();
244    hex::encode(digest)
245}
246
247fn is_64_hex(s: &str) -> bool {
248    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
249}
250
251fn coordinate_pubkey_part(s: &str) -> Option<&str> {
252    let mut parts = s.splitn(3, ':');
253    let _kind = parts.next()?;
254    let pubkey = parts.next()?;
255    parts.next()?;
256    Some(pubkey)
257}
258
259const fn hex_digit_value(byte: u8) -> u8 {
260    match byte {
261        b'0'..=b'9' => byte - b'0',
262        b'a'..=b'f' => byte - b'a' + 10,
263        b'A'..=b'F' => byte - b'A' + 10,
264        _ => 0,
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn hll_round_trip_hex() {
274        let mut hll = HyperLogLog::new();
275        hll.registers[0] = 5;
276        hll.registers[255] = 8;
277        let hex_str = hll.to_hex();
278        assert_eq!(hex_str.len(), HLL_HEX_LEN);
279        let parsed = HyperLogLog::from_hex(&hex_str).unwrap();
280        assert_eq!(parsed, hll);
281    }
282
283    #[test]
284    fn hll_merge_keeps_max() {
285        let mut a = HyperLogLog::new();
286        a.registers[0] = 3;
287        let mut b = HyperLogLog::new();
288        b.registers[0] = 5;
289        a.merge(&b);
290        assert_eq!(a.registers[0], 5);
291    }
292
293    #[test]
294    fn hll_offset_for_event_id() {
295        let event_id = "0".repeat(32) + "f" + &"0".repeat(31);
296        // Position 32 is `f` ⇒ 15. Plus 8 ⇒ 23.
297        assert_eq!(hll_offset_for_value(&event_id), 23);
298    }
299
300    #[test]
301    fn hll_offset_for_arbitrary_string_uses_sha256() {
302        // SHA-256("hello world") = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
303        // The 33rd char (position 32) is 'c' (12) → offset = 12 + 8 = 20.
304        let v = hll_offset_for_value("hello world");
305        assert_eq!(v, 20);
306    }
307
308    #[test]
309    fn hll_observe_records_leading_zeros() {
310        let mut hll = HyperLogLog::new();
311        // Single non-zero byte at the offset, rest zero ⇒ tail is all
312        // zero bytes (23 of them ⇒ 184 leading zero bits, plus the
313        // spec's `+1`).
314        let mut hash = [0u8; 32];
315        hash[8] = 0xff;
316        assert!(hll.observe(8, &hash));
317        assert_eq!(
318            hll.registers[0xff],
319            8u8.saturating_mul(23).saturating_add(1)
320        );
321    }
322
323    #[test]
324    fn hll_observe_records_high_first_bit() {
325        let mut hll = HyperLogLog::new();
326        let mut hash = [0u8; 32];
327        hash[8] = 0xff;
328        hash[9] = 0x80;
329        assert!(hll.observe(8, &hash));
330        // Tail starts with `0x80` ⇒ zero leading zero bits ⇒ register
331        // value = 1.
332        assert_eq!(hll.registers[0xff], 1);
333    }
334}