Skip to main content

nula_core/nips/
nip77.rs

1//! [NIP-77] Negentropy Syncing.
2//!
3//! NIP-77 wraps the binary [Negentropy](https://github.com/hoytech/negentropy)
4//! reconciliation protocol in three Nostr-style WebSocket messages:
5//!
6//! - `["NEG-OPEN", <subscription_id>, <filter>, <hex-payload>]`
7//! - `["NEG-MSG", <subscription_id>, <hex-payload>]`
8//! - `["NEG-CLOSE", <subscription_id>]`
9//! - `["NEG-ERR", <subscription_id>, <reason>]`
10//!
11//! This module provides a typed envelope ([`NegMessage`]) plus the
12//! Negentropy v1 binary primitives ([`NegProtocolVersion`],
13//! [`NegItem`], [`NegBound`], [`NegRange`], [`NegRangeMode`],
14//! [`NegPayload`]) and a low-level encoder/decoder
15//! ([`encode_payload`] / [`decode_payload`]).
16//!
17//! The full reconciliation algorithm (which delivers the IDs each
18//! side has / needs) lives downstream in the relay implementation;
19//! this module only models the wire bytes.
20//!
21//! [NIP-77]: https://github.com/nostr-protocol/nips/blob/master/77.md
22
23use sha2::{Digest, Sha256};
24use thiserror::Error;
25
26use crate::filter::Filter;
27use crate::message::SubscriptionId;
28use crate::util::hex;
29
30const FINGERPRINT_BYTES: usize = 16;
31const ID_BYTES: usize = 32;
32const INFINITY_TIMESTAMP: u64 = u64::MAX;
33const RESERVED_TIMESTAMP_INFINITY_OFFSET: u64 = 0;
34
35/// Protocol version byte. `1` ⇒ `0x61`, `2` ⇒ `0x62`, ….
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct NegProtocolVersion(pub u8);
38
39impl NegProtocolVersion {
40    /// Current protocol version (1).
41    pub const V1: Self = Self(0x61);
42
43    /// Construct a protocol version from a byte. Returns `None` if
44    /// the byte does not look like a valid version (i.e., not in
45    /// `0x60..=0x6f`).
46    #[must_use]
47    pub const fn from_byte(byte: u8) -> Option<Self> {
48        if byte >= 0x60 && byte < 0x70 {
49            Some(Self(byte))
50        } else {
51            None
52        }
53    }
54
55    /// Underlying byte.
56    #[must_use]
57    pub const fn as_byte(self) -> u8 {
58        self.0
59    }
60
61    /// Numeric version index (`0x61 ⇒ 1`).
62    #[must_use]
63    pub const fn version(self) -> u8 {
64        self.0.wrapping_sub(0x60)
65    }
66}
67
68/// A `(timestamp, id)` record participating in reconciliation.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub struct NegItem {
71    /// 64-bit unsigned timestamp. `u64::MAX` is the reserved
72    /// "infinity" sentinel and MUST NOT be used for real records.
73    pub timestamp: u64,
74    /// 32-byte event id.
75    pub id: [u8; ID_BYTES],
76}
77
78impl NegItem {
79    /// Construct an item.
80    #[must_use]
81    pub const fn new(timestamp: u64, id: [u8; ID_BYTES]) -> Self {
82        Self { timestamp, id }
83    }
84
85    /// Special "infinity" upper bound used as a sentinel.
86    #[must_use]
87    pub const fn infinity() -> Self {
88        Self {
89            timestamp: INFINITY_TIMESTAMP,
90            id: [0u8; ID_BYTES],
91        }
92    }
93}
94
95/// Half-open range bound `[upperTimestamp, idPrefix)` per spec
96/// §"Bound".
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct NegBound {
99    /// Upper-bound timestamp (special-cased: `0` ⇒ infinity in the
100    /// encoded form).
101    pub timestamp: u64,
102    /// Optional id-prefix bytes (0–32). Trailing bytes are implicitly
103    /// 0 when shorter than 32.
104    pub id_prefix: Vec<u8>,
105}
106
107impl NegBound {
108    /// Construct an infinity bound (used as the implicit final
109    /// `Skip` boundary).
110    #[must_use]
111    pub const fn infinity() -> Self {
112        Self {
113            timestamp: INFINITY_TIMESTAMP,
114            id_prefix: Vec::new(),
115        }
116    }
117}
118
119/// Mode of a [`NegRange`] payload.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum NegRangeMode {
122    /// `0` — sender does not wish to process this range further.
123    Skip,
124    /// `1` — sender carries a 16-byte fingerprint of all IDs within
125    /// the range.
126    Fingerprint([u8; FINGERPRINT_BYTES]),
127    /// `2` — sender carries the full id list within the range.
128    IdList(Vec<[u8; ID_BYTES]>),
129}
130
131/// A reconciliation range (upper-bound + payload).
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct NegRange {
134    /// Inclusive lower bound is implicit (the previous range's upper
135    /// bound, or zero for the first range).
136    pub upper_bound: NegBound,
137    /// Mode-tagged payload.
138    pub mode: NegRangeMode,
139}
140
141/// Decoded [Negentropy v1 message](https://github.com/hoytech/negentropy/blob/master/docs/protocol.md).
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct NegPayload {
144    /// Protocol version byte.
145    pub version: NegProtocolVersion,
146    /// Range list in ascending order.
147    pub ranges: Vec<NegRange>,
148}
149
150/// Payload bundle for [`NegMessage::Open`].
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct NegOpen {
153    /// Subscription id.
154    pub subscription_id: SubscriptionId,
155    /// Filter matching events to reconcile.
156    pub filter: Filter,
157    /// Initial Negentropy payload.
158    pub payload: NegPayload,
159}
160
161/// Wire-level NIP-77 envelope.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum NegMessage {
164    /// `["NEG-OPEN", <id>, <filter>, <hex-payload>]`.
165    Open(Box<NegOpen>),
166    /// `["NEG-MSG", <id>, <hex-payload>]`.
167    Msg {
168        /// Subscription id.
169        subscription_id: SubscriptionId,
170        /// Negentropy payload.
171        payload: NegPayload,
172    },
173    /// `["NEG-CLOSE", <id>]`.
174    Close {
175        /// Subscription id.
176        subscription_id: SubscriptionId,
177    },
178    /// `["NEG-ERR", <id>, <reason>]`.
179    Err {
180        /// Subscription id.
181        subscription_id: SubscriptionId,
182        /// Error reason (`<machine-prefix>: <human-message>` per
183        /// NIP-01 conventions).
184        reason: String,
185    },
186}
187
188/// Errors raised by Negentropy encoders / decoders.
189#[derive(Debug, Error)]
190#[non_exhaustive]
191pub enum NegentropyError {
192    /// Buffer ended before a varint terminator was reached.
193    #[error("unexpected end of input while decoding varint")]
194    UnexpectedEof,
195    /// Varint exceeds 10 bytes (would overflow `u64`).
196    #[error("varint exceeds 10 bytes")]
197    VarintOverflow,
198    /// Buffer ended while still waiting for `length` bytes of payload.
199    #[error("buffer ended {expected} bytes before payload completed")]
200    PayloadTruncated {
201        /// Bytes still expected.
202        expected: usize,
203    },
204    /// Unknown range mode tag.
205    #[error("unknown range mode {0}")]
206    UnknownRangeMode(u64),
207    /// Unsupported protocol version.
208    #[error("unsupported Negentropy protocol version 0x{0:02x}")]
209    UnsupportedVersion(u8),
210    /// Hex decode failure.
211    #[error("hex decode failure: {0}")]
212    Hex(String),
213}
214
215fn write_varint(value: u64, out: &mut Vec<u8>) {
216    let mut value = value;
217    let mut tmp: Vec<u8> = Vec::with_capacity(10);
218    loop {
219        let byte = u8::try_from(value & 0x7f).unwrap_or(0);
220        tmp.push(byte);
221        value >>= 7;
222        if value == 0 {
223            break;
224        }
225    }
226    // Most-significant-digit-first; flip the high bit on every byte
227    // except the last per spec.
228    for (i, byte) in tmp.iter().rev().enumerate() {
229        let with_continuation = if i + 1 < tmp.len() {
230            *byte | 0x80
231        } else {
232            *byte
233        };
234        out.push(with_continuation);
235    }
236}
237
238fn read_varint(buf: &[u8], cursor: &mut usize) -> Result<u64, NegentropyError> {
239    let mut value: u64 = 0;
240    for _ in 0..10 {
241        let byte = *buf.get(*cursor).ok_or(NegentropyError::UnexpectedEof)?;
242        *cursor += 1;
243        value = value
244            .checked_shl(7)
245            .ok_or(NegentropyError::VarintOverflow)?
246            | u64::from(byte & 0x7f);
247        if byte & 0x80 == 0 {
248            return Ok(value);
249        }
250    }
251    Err(NegentropyError::VarintOverflow)
252}
253
254fn encode_bound(bound: &NegBound, prev_timestamp: &mut u64, out: &mut Vec<u8>) {
255    let encoded_ts = if bound.timestamp == INFINITY_TIMESTAMP {
256        RESERVED_TIMESTAMP_INFINITY_OFFSET
257    } else {
258        // Offsets reset at the beginning of every message; the caller
259        // tracks the running value.
260        bound
261            .timestamp
262            .saturating_sub(*prev_timestamp)
263            .saturating_add(1)
264    };
265    write_varint(encoded_ts, out);
266    if bound.timestamp != INFINITY_TIMESTAMP {
267        *prev_timestamp = bound.timestamp;
268    }
269    let len = bound.id_prefix.len().min(ID_BYTES);
270    write_varint(len as u64, out);
271    out.extend_from_slice(bound.id_prefix.get(..len).unwrap_or(&[]));
272}
273
274fn decode_bound(
275    buf: &[u8],
276    cursor: &mut usize,
277    prev_timestamp: &mut u64,
278) -> Result<NegBound, NegentropyError> {
279    let ts_field = read_varint(buf, cursor)?;
280    let timestamp = if ts_field == RESERVED_TIMESTAMP_INFINITY_OFFSET {
281        INFINITY_TIMESTAMP
282    } else {
283        let value = prev_timestamp.saturating_add(ts_field.saturating_sub(1));
284        *prev_timestamp = value;
285        value
286    };
287    let len_u64 = read_varint(buf, cursor)?;
288    let len = usize::try_from(len_u64).map_err(|_| NegentropyError::VarintOverflow)?;
289    if len > ID_BYTES {
290        return Err(NegentropyError::PayloadTruncated { expected: len });
291    }
292    let chunk = read_chunk(buf, cursor, len)?;
293    Ok(NegBound {
294        timestamp,
295        id_prefix: chunk.to_vec(),
296    })
297}
298
299fn encode_range(range: &NegRange, prev_timestamp: &mut u64, out: &mut Vec<u8>) {
300    encode_bound(&range.upper_bound, prev_timestamp, out);
301    match &range.mode {
302        NegRangeMode::Skip => write_varint(0, out),
303        NegRangeMode::Fingerprint(fp) => {
304            write_varint(1, out);
305            out.extend_from_slice(fp);
306        }
307        NegRangeMode::IdList(ids) => {
308            write_varint(2, out);
309            write_varint(ids.len() as u64, out);
310            for id in ids {
311                out.extend_from_slice(id);
312            }
313        }
314    }
315}
316
317fn read_chunk<'a>(
318    buf: &'a [u8],
319    cursor: &mut usize,
320    len: usize,
321) -> Result<&'a [u8], NegentropyError> {
322    let end = cursor
323        .checked_add(len)
324        .ok_or(NegentropyError::VarintOverflow)?;
325    let chunk = buf.get(*cursor..end);
326    chunk.map_or_else(
327        || {
328            Err(NegentropyError::PayloadTruncated {
329                expected: end.saturating_sub(buf.len()),
330            })
331        },
332        |chunk| {
333            *cursor = end;
334            Ok(chunk)
335        },
336    )
337}
338
339fn decode_range(
340    buf: &[u8],
341    cursor: &mut usize,
342    prev_timestamp: &mut u64,
343) -> Result<NegRange, NegentropyError> {
344    let upper_bound = decode_bound(buf, cursor, prev_timestamp)?;
345    let mode = read_varint(buf, cursor)?;
346    let mode = match mode {
347        0 => NegRangeMode::Skip,
348        1 => {
349            let chunk = read_chunk(buf, cursor, FINGERPRINT_BYTES)?;
350            let mut fp = [0u8; FINGERPRINT_BYTES];
351            fp.copy_from_slice(chunk);
352            NegRangeMode::Fingerprint(fp)
353        }
354        2 => {
355            let count_u64 = read_varint(buf, cursor)?;
356            let count = usize::try_from(count_u64).map_err(|_| NegentropyError::VarintOverflow)?;
357            let mut ids = Vec::with_capacity(count);
358            for _ in 0..count {
359                let chunk = read_chunk(buf, cursor, ID_BYTES)?;
360                let mut id = [0u8; ID_BYTES];
361                id.copy_from_slice(chunk);
362                ids.push(id);
363            }
364            NegRangeMode::IdList(ids)
365        }
366        other => return Err(NegentropyError::UnknownRangeMode(other)),
367    };
368    Ok(NegRange { upper_bound, mode })
369}
370
371/// Encode a [`NegPayload`] to its raw bytes. Use `hex::encode` to
372/// produce the wire-format hex string carried by [`NegMessage`].
373#[must_use]
374pub fn encode_payload(payload: &NegPayload) -> Vec<u8> {
375    let mut out = Vec::with_capacity(1 + payload.ranges.len() * 32);
376    out.push(payload.version.as_byte());
377    let mut prev_timestamp: u64 = 0;
378    for range in &payload.ranges {
379        encode_range(range, &mut prev_timestamp, &mut out);
380    }
381    out
382}
383
384/// Decode raw bytes into a [`NegPayload`].
385///
386/// # Errors
387///
388/// See [`NegentropyError`] for the failure modes.
389pub fn decode_payload(buf: &[u8]) -> Result<NegPayload, NegentropyError> {
390    let (version_byte, rest) = buf.split_first().ok_or(NegentropyError::UnexpectedEof)?;
391    let version = NegProtocolVersion::from_byte(*version_byte)
392        .ok_or(NegentropyError::UnsupportedVersion(*version_byte))?;
393    let mut cursor = 0;
394    let mut prev_timestamp: u64 = 0;
395    let mut ranges = Vec::new();
396    while cursor < rest.len() {
397        ranges.push(decode_range(rest, &mut cursor, &mut prev_timestamp)?);
398    }
399    Ok(NegPayload { version, ranges })
400}
401
402/// Encode the payload as the wire-format hex string carried by
403/// [`NegMessage::Open`] / [`NegMessage::Msg`].
404#[must_use]
405pub fn encode_payload_hex(payload: &NegPayload) -> String {
406    hex::encode(encode_payload(payload))
407}
408
409/// Decode the wire-format hex payload.
410///
411/// # Errors
412///
413/// Wraps `hex` decoding errors and propagates [`NegentropyError`]
414/// from the binary decoder.
415pub fn decode_payload_hex(hex_str: &str) -> Result<NegPayload, NegentropyError> {
416    let bytes = hex::decode(hex_str).map_err(|e| NegentropyError::Hex(e.to_string()))?;
417    decode_payload(&bytes)
418}
419
420/// Compute a Negentropy v1 fingerprint over a list of event IDs.
421///
422/// 1. Sum the IDs as 32-byte little-endian unsigned integers modulo
423///    `2**256`.
424/// 2. Append the count as a varint.
425/// 3. SHA-256 the concatenation.
426/// 4. Take the first 16 bytes.
427#[must_use]
428pub fn fingerprint(ids: &[[u8; ID_BYTES]]) -> [u8; FINGERPRINT_BYTES] {
429    let mut sum = [0u8; ID_BYTES];
430    for id in ids {
431        let mut carry: u16 = 0;
432        for (sum_byte, id_byte) in sum.iter_mut().zip(id.iter()) {
433            let total = u16::from(*sum_byte) + u16::from(*id_byte) + carry;
434            *sum_byte = u8::try_from(total & 0xff).unwrap_or(0);
435            carry = total >> 8;
436        }
437    }
438    let mut hasher = Sha256::new();
439    hasher.update(sum);
440    let mut count_buf = Vec::new();
441    write_varint(ids.len() as u64, &mut count_buf);
442    hasher.update(&count_buf);
443    let digest = hasher.finalize();
444    let mut out = [0u8; FINGERPRINT_BYTES];
445    for (slot, byte) in out.iter_mut().zip(digest.iter()) {
446        *slot = *byte;
447    }
448    out
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn varint_round_trip() {
457        for &value in &[0u64, 1, 127, 128, 300, 1_000_000, u64::MAX / 2] {
458            let mut buf = Vec::new();
459            write_varint(value, &mut buf);
460            let mut cursor = 0;
461            assert_eq!(read_varint(&buf, &mut cursor).unwrap(), value);
462            assert_eq!(cursor, buf.len());
463        }
464    }
465
466    #[test]
467    fn payload_round_trip() {
468        let payload = NegPayload {
469            version: NegProtocolVersion::V1,
470            ranges: vec![
471                NegRange {
472                    upper_bound: NegBound {
473                        timestamp: 1_700_000_000,
474                        id_prefix: vec![0xab, 0xcd],
475                    },
476                    mode: NegRangeMode::Fingerprint([0xff; FINGERPRINT_BYTES]),
477                },
478                NegRange {
479                    upper_bound: NegBound::infinity(),
480                    mode: NegRangeMode::Skip,
481                },
482            ],
483        };
484        let bytes = encode_payload(&payload);
485        let parsed = decode_payload(&bytes).unwrap();
486        assert_eq!(parsed, payload);
487    }
488
489    #[test]
490    fn id_list_payload_round_trip() {
491        let ids = vec![[0x11; 32], [0x22; 32]];
492        let payload = NegPayload {
493            version: NegProtocolVersion::V1,
494            ranges: vec![NegRange {
495                upper_bound: NegBound::infinity(),
496                mode: NegRangeMode::IdList(ids.clone()),
497            }],
498        };
499        let hex_str = encode_payload_hex(&payload);
500        let parsed = decode_payload_hex(&hex_str).unwrap();
501        match &parsed.ranges[0].mode {
502            NegRangeMode::IdList(decoded) => assert_eq!(decoded, &ids),
503            other => panic!("unexpected mode {other:?}"),
504        }
505    }
506
507    #[test]
508    fn unsupported_version_is_rejected() {
509        let bytes = vec![0x10];
510        assert!(matches!(
511            decode_payload(&bytes),
512            Err(NegentropyError::UnsupportedVersion(0x10))
513        ));
514    }
515
516    #[test]
517    fn fingerprint_is_stable() {
518        let ids = vec![[1u8; 32], [2u8; 32]];
519        let fp = fingerprint(&ids);
520        let fp_again = fingerprint(&ids);
521        assert_eq!(fp, fp_again);
522        let fp_other = fingerprint(&[[3u8; 32]]);
523        assert_ne!(fp, fp_other);
524    }
525}