Skip to main content

vector_core/
negentropy.rs

1//! Shared NIP-77 negentropy set reconciliation.
2//!
3//! One acquisition primitive for the whole app. DM and community sync differ only
4//! in their fingerprint source and processing; the reconcile-against-relays
5//! step is identical, so it lives here.
6
7use std::collections::HashSet;
8use std::time::Duration;
9
10use nostr_sdk::prelude::*;
11
12// ============================================================================
13// NIP-77 capability cache
14// ============================================================================
15//
16// Some relay software never implemented negentropy (nostr-rs-relay answers a
17// NEG-OPEN with an unrecognized NOTICE at best), and nostr-sdk's support check
18// only recognizes strfry-style refusals — so every sync against such a relay
19// silently burns the full initial_timeout before failing. Verdicts persist in
20// the account settings KV with a TTL: boots skip doomed reconciles, and a
21// relay that upgrades its software is re-probed within a day.
22
23const NEG_CAP_TTL_SECS: u64 = 24 * 3600;
24
25fn cap_key(relay_url: &str) -> String {
26    format!("neg_cap:{}", relay_url.trim_end_matches('/'))
27}
28
29fn now_secs() -> u64 {
30    std::time::SystemTime::now()
31        .duration_since(std::time::UNIX_EPOCH)
32        .map(|d| d.as_secs())
33        .unwrap_or(0)
34}
35
36/// Cached NIP-77 verdict for a relay. `None` = unknown or stale — attempt
37/// negentropy and let the outcome refresh the cache.
38pub fn neg_supported_cached(relay_url: &str) -> Option<bool> {
39    let raw = crate::db::get_sql_setting(cap_key(relay_url)).ok()??;
40    let (supported, checked_at) = parse_cap_entry(&raw)?;
41    (now_secs().saturating_sub(checked_at) < NEG_CAP_TTL_SECS).then_some(supported)
42}
43
44/// Persist a fresh verdict (value format: `0|1:<unix seconds>`). The verdict is
45/// relay-global truth, so a raced write is wasted work, never wrong data.
46pub fn record_neg_support(relay_url: &str, supported: bool) {
47    let _ = crate::db::set_sql_setting(
48        cap_key(relay_url),
49        format!("{}:{}", u8::from(supported), now_secs()),
50    );
51}
52
53fn parse_cap_entry(raw: &str) -> Option<(bool, u64)> {
54    let (flag, ts) = raw.split_once(':')?;
55    let supported = match flag {
56        "1" => true,
57        "0" => false,
58        _ => return None,
59    };
60    Some((supported, ts.parse().ok()?))
61}
62
63/// Interpret a `relay.sync` error. `Some(false)` = the relay cannot
64/// reconcile: either the SDK recognized the refusal outright, or a relay that
65/// was CONNECTED stayed silent past the initial timeout — healthy negentropy
66/// implementations answer the first frame in well under a second, so silence
67/// on a live connection is the no-implementation signature. A timeout on a
68/// relay that wasn't connected is an outage and classifies as nothing.
69/// Wait briefly for a relay to reach Connected before a sync attempt.
70/// `false` = never connected inside the allowance — callers treat that as a
71/// TRANSIENT skip (no verdict, no skip-list, no cursor touch): an unreachable
72/// relay must cost the allowance, not a full negentropy initial_timeout. The
73/// Monitor-driven reconnect catch-up covers it the moment it truly connects.
74pub async fn wait_connected(relay: &Relay, allowance: Duration) -> bool {
75    let deadline = tokio::time::Instant::now() + allowance;
76    loop {
77        match relay.status() {
78            RelayStatus::Connected => return true,
79            RelayStatus::Terminated | RelayStatus::Banned => return false,
80            _ => {}
81        }
82        if tokio::time::Instant::now() >= deadline {
83            return false;
84        }
85        tokio::time::sleep(Duration::from_millis(150)).await;
86    }
87}
88
89// ============================================================================
90// Per-relay reconcile cursor
91// ============================================================================
92//
93// "Reconcile-verified through T": the boot quick sync reconciles a cursored
94// relay only from (cursor − NIP-59 slack), and the full-history pass runs
95// solely as a one-time bootstrap for relays with no cursor. A cursor advances
96// exclusively on PROOF — a zero-missing reconcile (the wrapper ledger only
97// gains rows after commit, so zero-missing means relay and ledger agree
98// through the window), or a bootstrap whose every requested event was
99// actually received. Never on a failed or partial pass: a wrong cursor
100// silently loses history, a stalled one merely re-reconciles a small window.
101
102fn cursor_key(relay_url: &str) -> String {
103    format!("neg_cursor:{}", relay_url.trim_end_matches('/'))
104}
105
106/// The relay's reconcile cursor (unix seconds), if it has ever earned one.
107pub fn reconcile_cursor(relay_url: &str) -> Option<u64> {
108    crate::db::get_sql_setting(cursor_key(relay_url)).ok()??.parse().ok()
109}
110
111/// Monotonic advance — one SQL upsert (the stored value only grows), so a
112/// late or concurrent writer can never regress the cursor. Session-gated
113/// IMMEDIATELY before the write: a swap landing after the caller's own check
114/// must not stamp this account's cursor into the next account's KV — that
115/// would both skew its quick window and silently skip its bootstrap.
116pub fn advance_reconcile_cursor(relay_url: &str, anchor_secs: u64) {
117    let _ = crate::db::advance_u64_setting(cursor_key(relay_url), anchor_secs);
118}
119
120/// True when a `relay.sync` failure says nothing durable about the relay —
121/// connection-state errors and timeouts. Only deterministic refusals (protocol
122/// errors, query caps) repeat identically on a later attempt, so only those
123/// belong on a same-boot skip-list; a connected relay that timed out is
124/// handled through the capability cache instead.
125pub fn is_transient_sync_error(err: &str) -> bool {
126    err == "timeout"
127        || err.contains("not connected")
128        || err.contains("transport dispatcher")
129        // SDK notification broadcast overflow on a busy connection — says
130        // nothing about the relay at all.
131        || err.contains("lagged")
132}
133
134pub fn classify_neg_sync_error(err: &str, relay_was_connected: bool) -> Option<bool> {
135    if err.contains("negentropy not supported")
136        || err.contains("unknown negentropy error")
137        || (err.contains("negentropy") && err.contains("protocol version"))
138    {
139        return Some(false);
140    }
141    if err == "timeout" && relay_was_connected {
142        return Some(false);
143    }
144    None
145}
146
147/// Race every trusted relay exchanging negentropy fingerprints for `filter`,
148/// and return the union of event IDs that relays hold but we don't.
149///
150/// `local_items` is our fingerprint set: `(event_id, created_at)` for
151/// everything we already possess. Each relay reports only the IDs absent from
152/// that set, so the union across relays is the complete missing set reachable
153/// from our trusted relays.
154///
155/// Every relay is drained (not just the first to respond): completeness beats
156/// latency here, and one relay may lack events another holds. Each relay is
157/// bounded by `timeout`.
158pub async fn reconcile_missing(
159    filter: Filter,
160    local_items: Vec<(EventId, Timestamp)>,
161    timeout: Duration,
162) -> Result<HashSet<EventId>, String> {
163    crate::db::scoped(async move {
164        use futures_util::stream::{FuturesUnordered, StreamExt};
165
166        let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
167
168        let opts = SyncOptions::new()
169            .direction(SyncDirection::Down)
170            .initial_timeout(timeout)
171            .dry_run();
172
173        // Resolve trusted relay URLs to live Relay handles, skipping relays with a
174        // fresh no-NIP-77 verdict — each would burn the full timeout for nothing.
175        let relay_map = client.relays().await;
176        let trusted = crate::state::active_trusted_relays().await;
177        let relays: Vec<(String, Relay)> = trusted.iter().filter_map(|url| {
178            if neg_supported_cached(url) == Some(false) {
179                crate::log_debug!("[Negentropy] {} skipped (cached: no NIP-77)", url);
180                return None;
181            }
182            let normalized = url.trim_end_matches('/');
183            relay_map.iter()
184                .find(|(u, _)| u.as_str().trim_end_matches('/') == normalized)
185                .map(|(_, r)| (url.to_string(), r.clone()))
186        }).collect();
187        drop(relay_map);
188
189        if relays.is_empty() {
190            crate::log_warn!("[Negentropy] No trusted relays available for reconciliation");
191            return Ok(HashSet::new());
192        }
193
194        let connect_allowance = crate::relay_request_timeout(Duration::from_secs(3)).min(timeout);
195        let mut futs = FuturesUnordered::new();
196        for (url, relay) in &relays {
197            let url = url.clone();
198            let relay = relay.clone();
199            let f = filter.clone();
200            let items = local_items.clone();
201            let o = opts.clone();
202            futs.push(async move {
203                if !wait_connected(&relay, connect_allowance).await {
204                    return (url, None, false);
205                }
206                let r = tokio::time::timeout(timeout, relay.sync(f).items(items).opts(o)).await;
207                let connected = relay.status() == RelayStatus::Connected;
208                (url, Some(r), connected)
209            });
210        }
211
212        let session = crate::db::current_session();
213        let mut missing: HashSet<EventId> = HashSet::new();
214        while let Some((url, result, connected)) = futs.next().await {
215            let Some(result) = result else {
216                crate::log_debug!("[Negentropy] {} skipped: not connected", url);
217                continue;
218            };
219            match result {
220                Ok(Ok(recon)) => {
221                    let n = recon.remote.len();
222                    missing.extend(recon.remote);
223                    crate::log_debug!("[Negentropy] {} reconciled: {} missing", url, n);
224                    record_neg_support(&url, true);
225                }
226                Ok(Err(e)) => {
227                    crate::log_warn!("[Negentropy] {} failed: {}", url, e);
228                    if session.is_live()
229                        && classify_neg_sync_error(&e.to_string(), connected) == Some(false)
230                    {
231                        crate::log_info!("[Negentropy] {} marked no-NIP-77 for 24h", url);
232                        record_neg_support(&url, false);
233                    }
234                }
235                Err(_) => crate::log_warn!("[Negentropy] {} timed out", url),
236            }
237        }
238
239        Ok(missing)
240    })
241    .await
242}
243
244#[cfg(test)]
245mod cap_tests {
246    use super::*;
247
248    #[test]
249    fn classify_detects_deterministic_refusals_regardless_of_connection() {
250        for err in [
251            "negentropy not supported",
252            "unknown negentropy error",
253            "negentropy: unsupported protocol version",
254        ] {
255            assert_eq!(classify_neg_sync_error(err, true), Some(false), "{err}");
256            assert_eq!(classify_neg_sync_error(err, false), Some(false), "{err}");
257        }
258    }
259
260    #[test]
261    fn classify_timeout_only_counts_when_connected() {
262        assert_eq!(classify_neg_sync_error("timeout", true), Some(false));
263        assert_eq!(classify_neg_sync_error("timeout", false), None);
264    }
265
266    #[test]
267    fn classify_ignores_unrelated_errors() {
268        for err in [
269            "auth-required: we can't serve DMs to unauthenticated users",
270            "relay message too large: size=200000, max_size=131072",
271            "not connected",
272            "timeout exceeded", // only the SDK's exact Display counts
273        ] {
274            assert_eq!(classify_neg_sync_error(err, true), None, "{err}");
275        }
276    }
277
278    #[test]
279    fn transient_errors_never_skip_the_archive() {
280        assert!(is_transient_sync_error("timeout"));
281        assert!(is_transient_sync_error("relay not connected"));
282        assert!(is_transient_sync_error("not connected"));
283        assert!(is_transient_sync_error("can't send message to the transport dispatcher"));
284        assert!(is_transient_sync_error("channel lagged by 558"));
285        assert!(!is_transient_sync_error("negentropy not supported"));
286        assert!(!is_transient_sync_error("unknown negentropy error"));
287        assert!(!is_transient_sync_error("blocked: sync too big"));
288    }
289
290    #[test]
291    fn cap_entry_parses_and_rejects() {
292        assert_eq!(parse_cap_entry("1:1753900000"), Some((true, 1753900000)));
293        assert_eq!(parse_cap_entry("0:42"), Some((false, 42)));
294        assert_eq!(parse_cap_entry("2:42"), None);
295        assert_eq!(parse_cap_entry("1:"), None);
296        assert_eq!(parse_cap_entry("1"), None);
297        assert_eq!(parse_cap_entry("nonsense"), None);
298        assert_eq!(parse_cap_entry(""), None);
299    }
300
301    #[test]
302    fn cap_key_normalizes_trailing_slash() {
303        assert_eq!(cap_key("wss://r.example/"), cap_key("wss://r.example"));
304        assert_eq!(cursor_key("wss://r.example/"), cursor_key("wss://r.example"));
305    }
306
307}