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