Skip to main content

softchat/
negentropy.rs

1//! Bounded NIP-77 Negentropy client reconciliation.
2
3use std::collections::BTreeSet;
4use std::fmt;
5use std::sync::{Mutex, MutexGuard};
6
7use serde::{Deserialize, Serialize};
8use softrelay_negentropy::{Id, Negentropy, NegentropyStorageVector};
9
10use crate::{NostrEventId, SoftchatError};
11
12/// Maximum items accepted by one portable reconciliation capability.
13pub const MAX_NEGENTROPY_ITEMS: usize = 100_000;
14/// Smallest accepted non-zero Negentropy frame limit.
15pub const MIN_NEGENTROPY_FRAME_BYTES: u64 = 4_096;
16/// Largest Negentropy frame allowed inside the 512-KiB relay profile.
17pub const MAX_NEGENTROPY_FRAME_BYTES: u64 = 512 * 1_024;
18
19/// One event timestamp/ID pair in a local reconciliation set.
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
23pub struct NegentropyItem {
24    /// Portable non-negative Nostr event timestamp.
25    pub created_at: i64,
26    /// Canonical lowercase event ID.
27    pub event_id: String,
28}
29
30/// Result of one stateful client reconciliation step.
31#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
34pub struct NegentropyStep {
35    /// Next binary query to send, absent when reconciliation is complete.
36    pub outgoing: Option<Vec<u8>>,
37    /// IDs present locally and absent remotely in this step.
38    pub have_event_ids: Vec<String>,
39    /// IDs absent locally and present remotely in this step.
40    pub need_event_ids: Vec<String>,
41    /// Whether no further protocol frame is needed.
42    pub complete: bool,
43}
44
45/// Serialized, bounded Negentropy initiator.
46#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
47pub struct NegentropyClient {
48    engine: Mutex<Negentropy<'static, NegentropyStorageVector>>,
49    initiated: Mutex<bool>,
50}
51
52impl fmt::Debug for NegentropyClient {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        formatter
55            .debug_struct("NegentropyClient")
56            .finish_non_exhaustive()
57    }
58}
59
60#[cfg_attr(feature = "native-bindings", uniffi::export)]
61impl NegentropyClient {
62    /// Build a reconciliation capability from one bounded local event set.
63    ///
64    /// # Errors
65    ///
66    /// Returns a stable redacted error for invalid IDs, timestamps, duplicate
67    /// IDs, excessive item count, or an out-of-profile frame limit.
68    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
69    pub fn new(items: Vec<NegentropyItem>, frame_size_limit: u64) -> Result<Self, SoftchatError> {
70        if items.len() > MAX_NEGENTROPY_ITEMS
71            || !(MIN_NEGENTROPY_FRAME_BYTES..=MAX_NEGENTROPY_FRAME_BYTES)
72                .contains(&frame_size_limit)
73        {
74            return Err(SoftchatError::InvalidNegentropy);
75        }
76
77        let mut storage = NegentropyStorageVector::with_capacity(items.len());
78        let mut unique_ids = BTreeSet::new();
79        for item in items {
80            let created_at =
81                u64::try_from(item.created_at).map_err(|_| SoftchatError::InvalidNegentropy)?;
82            let event_id = NostrEventId::from_hex(&item.event_id)
83                .map_err(|_| SoftchatError::InvalidNegentropy)?;
84            if !unique_ids.insert(event_id.to_hex()) {
85                return Err(SoftchatError::InvalidNegentropy);
86            }
87            storage
88                .insert(
89                    created_at,
90                    Id::from_byte_array(*event_id.as_inner().as_bytes()),
91                )
92                .map_err(|_| SoftchatError::InvalidNegentropy)?;
93        }
94        storage
95            .seal()
96            .map_err(|_| SoftchatError::InvalidNegentropy)?;
97        let engine = Negentropy::owned(storage, frame_size_limit)
98            .map_err(|_| SoftchatError::InvalidNegentropy)?;
99        Ok(Self {
100            engine: Mutex::new(engine),
101            initiated: Mutex::new(false),
102        })
103    }
104
105    /// Create the first binary Negentropy query.
106    ///
107    /// # Errors
108    ///
109    /// Returns a stable redacted error when called more than once.
110    pub fn initiate(&self) -> Result<Vec<u8>, SoftchatError> {
111        let mut initiated = lock(&self.initiated);
112        if *initiated {
113            return Err(SoftchatError::InvalidNegentropy);
114        }
115        let output = lock(&self.engine)
116            .initiate()
117            .map_err(|_| SoftchatError::InvalidNegentropy)?;
118        *initiated = true;
119        Ok(output)
120    }
121
122    /// Process one relay response and advance reconciliation.
123    ///
124    /// # Errors
125    ///
126    /// Returns a stable redacted error before initiation or for any malformed,
127    /// oversized, out-of-order, or contradictory protocol frame.
128    pub fn reconcile(&self, response: Vec<u8>) -> Result<NegentropyStep, SoftchatError> {
129        if !*lock(&self.initiated) || response.len() > MAX_NEGENTROPY_FRAME_BYTES as usize {
130            return Err(SoftchatError::InvalidNegentropy);
131        }
132        let mut have_ids = Vec::new();
133        let mut need_ids = Vec::new();
134        let outgoing = lock(&self.engine)
135            .reconcile_with_ids(&response, &mut have_ids, &mut need_ids)
136            .map_err(|_| SoftchatError::InvalidNegentropy)?;
137        let have_event_ids = have_ids
138            .into_iter()
139            .map(|id| hex::encode(id.to_bytes()))
140            .collect();
141        let need_event_ids = need_ids
142            .into_iter()
143            .map(|id| hex::encode(id.to_bytes()))
144            .collect();
145        Ok(NegentropyStep {
146            complete: outgoing.is_none(),
147            outgoing,
148            have_event_ids,
149            need_event_ids,
150        })
151    }
152}
153
154fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
155    match mutex.lock() {
156        Ok(guard) => guard,
157        Err(poisoned) => poisoned.into_inner(),
158    }
159}
160
161#[cfg(test)]
162#[allow(clippy::unwrap_used)]
163mod tests {
164    use softrelay_negentropy::{NegentropyStorageVector, Storage};
165
166    use super::*;
167
168    fn item(created_at: i64, byte: u8) -> NegentropyItem {
169        NegentropyItem {
170            created_at,
171            event_id: hex::encode([byte; 32]),
172        }
173    }
174
175    #[test]
176    fn reconciles_against_reviewed_softrelay_engine() {
177        let client = NegentropyClient::new(vec![item(1, 0x11), item(3, 0x33)], 4_096).unwrap();
178        let initial = client.initiate().unwrap();
179
180        let mut relay_storage = NegentropyStorageVector::new();
181        relay_storage
182            .insert(2, Id::from_byte_array([0x22; 32]))
183            .unwrap();
184        relay_storage
185            .insert(3, Id::from_byte_array([0x33; 32]))
186            .unwrap();
187        relay_storage.seal().unwrap();
188        let mut relay = Negentropy::new(Storage::Borrowed(&relay_storage), 4_096).unwrap();
189
190        let response = relay.reconcile(&initial).unwrap();
191        let step = client.reconcile(response).unwrap();
192        assert!(step.complete);
193        assert_eq!(step.have_event_ids, vec![hex::encode([0x11; 32])]);
194        assert_eq!(step.need_event_ids, vec![hex::encode([0x22; 32])]);
195    }
196
197    #[test]
198    fn bounds_ids_timestamps_frames_and_state_order() {
199        assert!(matches!(
200            NegentropyClient::new(vec![item(-1, 0x11)], 4_096),
201            Err(SoftchatError::InvalidNegentropy)
202        ));
203        assert!(matches!(
204            NegentropyClient::new(vec![item(1, 0x11), item(2, 0x11)], 4_096),
205            Err(SoftchatError::InvalidNegentropy)
206        ));
207        assert!(matches!(
208            NegentropyClient::new(vec![], 4_095),
209            Err(SoftchatError::InvalidNegentropy)
210        ));
211        let client = NegentropyClient::new(vec![], 4_096).unwrap();
212        assert!(matches!(
213            client.reconcile(vec![0x61]),
214            Err(SoftchatError::InvalidNegentropy)
215        ));
216        client.initiate().unwrap();
217        assert!(matches!(
218            client.initiate(),
219            Err(SoftchatError::InvalidNegentropy)
220        ));
221    }
222}