diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index cb7c5307..ce649a04 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -8,7 +8,7 @@ use instant::Duration; use itertools::Itertools; use nip4e::DeviceRegistry; use nostr_sdk::prelude::*; -use person::{Directory, Person, WebOfTrust}; +use person::{Directory, Person, PersonRegistry}; use settings::{AppSettings, RoomConfig, SignerKind}; use state::{NostrRegistry, TIMEOUT, UniversalSigner}; @@ -190,7 +190,7 @@ impl Room { return false; } - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); Self::trusted_by(&self.members, me, |key| wot.is_in_network(key)) } @@ -203,7 +203,7 @@ impl Room { return false; }; - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); Self::trusted_by(&self.members, me, |key| !wot.is_outside(key)) } diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 48caaf9f..d885e079 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -19,7 +19,6 @@ pub use wot::*; pub fn init(cx: &mut App) { PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); - WebOfTrust::set_global(cx.new(WebOfTrust::new), cx); } struct GlobalPersonRegistry(Entity); @@ -63,6 +62,8 @@ enum Dispatch { pub struct PersonRegistry { /// Public keys with a metadata request in flight in_flight: RwLock>, + /// Web of trust graph of the signed-in user + wot: Entity, sender: flume::Sender, tasks: SmallVec<[Task<()>; 4]>, _subscriptions: SmallVec<[Subscription; 1]>, @@ -83,6 +84,7 @@ impl PersonRegistry { fn new(cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let wot = cx.new(WebOfTrust::new); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(100); @@ -125,17 +127,28 @@ impl PersonRegistry { let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| { if event.signer_changed() { this.get_emojis(cx); + + this.wot.update(cx, |wot, cx| { + wot.reset(cx); + wot.hydrate(cx); + }); } }); Self { in_flight: RwLock::new(HashSet::new()), + wot, sender: metadata_tx, tasks, _subscriptions: smallvec![subscription], } } + /// The web of trust graph of the signed-in user + pub fn wot(&self) -> Entity { + self.wot.clone() + } + /// Handle nostr notifications async fn handle_notifications(client: &Client, tx: &flume::Sender) { let mut notifications = client.notifications(); diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index 58ff5f96..fead0fcd 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use gpui::{AppContext, Context, EventEmitter, Task}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -11,20 +11,11 @@ const MAX_FOLLOWS: usize = 2000; /// Cap a followed author's contact list to bound hostile publishers const MAX_TAGS_PER_LIST: usize = 5000; /// Time between reconciliations with the relays -const SYNC_INTERVAL: u64 = 15 * 60; +const INTERVAL: u64 = 15 * 60; -struct GlobalWebOfTrust(Entity); - -impl Global for GlobalWebOfTrust {} - -/// A change to the web of trust +/// The web of trust graph changed #[derive(Debug, Clone)] -pub enum WoTEvent { - /// The graph changed - Updated, - /// The initial load finished - Ready, -} +pub struct WoTEvent; impl EventEmitter for WebOfTrust {} @@ -48,38 +39,22 @@ pub struct WebOfTrust { disabled: bool, /// Whether the initial load has finished ready: bool, - /// Whether the next refresh completes the initial load - initial: bool, /// Task loading the graph, replaced by newer loads hydration: Option>, /// Tasks for asynchronous operations tasks: SmallVec<[Task<()>; 4]>, - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, } impl WebOfTrust { - /// Retrieve the global web of trust - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - /// Set the global web of trust instance - pub(crate) fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalWebOfTrust(state)); - } - /// Create a new web of trust instance pub(crate) fn new(cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); let entity = cx.entity().downgrade(); - let mut tasks = smallvec![]; tasks.push(cx.spawn(async move |this, cx| { loop { cx.background_executor() - .timer(Duration::from_secs(SYNC_INTERVAL)) + .timer(Duration::from_secs(INTERVAL)) .await; // Ignore errors, the next refresh will retry let _ = this.update(cx, |this, cx| { @@ -88,13 +63,6 @@ impl WebOfTrust { } })); - let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| { - if event.signer_changed() { - this.reset(cx); - this.hydrate(cx); - } - }); - cx.defer(move |cx| { let _ = entity.update(cx, |this, cx| { this.hydrate(cx); @@ -109,23 +77,21 @@ impl WebOfTrust { reverse: HashMap::new(), disabled: false, ready: false, - initial: false, hydration: None, tasks, - _subscriptions: smallvec![subscription], } } /// Load the graph for the current account - fn hydrate(&mut self, cx: &mut Context) { + pub(crate) fn hydrate(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let Some(self_key) = nostr.read(cx).current_user() else { return; }; self.self_key = Some(self_key); - self.initial = true; // The locally stored contact list, applied before the relays respond let local: Task> = cx.background_spawn(async move { @@ -155,7 +121,7 @@ impl WebOfTrust { // Credit the local graph before the relays respond this.update(cx, |this, cx| { if this.self_key == Some(self_key) && this.store_follows(at, snapshot.clone()) { - cx.emit(WoTEvent::Updated); + cx.emit(WoTEvent); cx.notify(); } }) @@ -206,16 +172,12 @@ impl WebOfTrust { let mut grew = false; this.update(cx, |this, cx| { + let was_ready = this.ready; let changed = this.apply_latest(self_key, events, cx); + this.ready = true; - if this.initial { - this.initial = false; - this.ready = true; - cx.emit(WoTEvent::Ready); - } - - if changed { - cx.emit(WoTEvent::Updated); + if changed || !was_ready { + cx.emit(WoTEvent); cx.notify(); } @@ -407,7 +369,7 @@ impl WebOfTrust { } /// Clear the graph on sign out or account switch - fn reset(&mut self, cx: &mut Context) { + pub(crate) fn reset(&mut self, cx: &mut Context) { self.self_key = None; self.follows.clear(); self.follows_at = None; @@ -415,10 +377,9 @@ impl WebOfTrust { self.reverse.clear(); self.disabled = false; self.ready = false; - self.initial = false; self.hydration = None; - cx.emit(WoTEvent::Updated); + cx.emit(WoTEvent); cx.notify(); } @@ -450,20 +411,10 @@ impl WebOfTrust { }) } - /// Whether the initial load has finished - pub fn is_ready(&self) -> bool { - self.ready - } - /// Whether the graph is loaded and score queries are meaningful pub fn is_loaded(&self) -> bool { self.ready && !self.disabled } - - /// Whether scoring is disabled due to an oversized follow set - pub fn is_disabled(&self) -> bool { - self.disabled - } } #[cfg(test)] @@ -487,10 +438,8 @@ mod tests { reverse: HashMap::new(), disabled: false, ready: false, - initial: false, hydration: None, tasks: smallvec![], - _subscriptions: smallvec![], } } diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index fceedd78..9e4d950e 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, PersonRegistry, WebOfTrust, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; use state::{NostrRegistry, upload}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -460,7 +460,7 @@ impl Render for ProfileDialog { .map(SharedString::from); let score = { - let wot = WebOfTrust::global(cx).read(cx); + let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); wot.is_loaded().then(|| wot.score(&self.public_key)) } .map(|score| { diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 819b453b..a8d315c9 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -9,7 +9,7 @@ use gpui::{ }; use instant::Duration; use nostr_sdk::prelude::*; -use person::{Directory, Person, WebOfTrust, shorten_pubkey}; +use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use smallvec::{SmallVec, smallvec}; use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius}; @@ -33,7 +33,7 @@ pub struct Screening { last_active: Option, /// All mutual contacts of the person being screened. mutual_contacts: Vec, - /// Whether the database fallback for mutual contacts has run. + /// Whether the fallback lookup for mutual contacts has run. mutuals_fallback: bool, /// Person directory for the person and their mutual contacts directory: Entity, @@ -43,7 +43,7 @@ pub struct Screening { impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let wot = WebOfTrust::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); let directory = Directory::new(cx); let mut subscriptions = smallvec![]; @@ -53,8 +53,8 @@ impl Screening { window.close_all_dialogs(cx); })); - subscriptions.push(cx.subscribe(&wot, |this, _wot, _event, cx| { - this.check_wot(cx); + subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| { + cx.notify(); })); directory.update(cx, |directory, cx| { @@ -63,7 +63,7 @@ impl Screening { cx.defer_in(window, |this, _window, cx| { this.check_contact(cx); - this.check_wot(cx); + this.fetch_fallback(cx); this.check_last_activity(cx); this.verify_identifier(cx); }); @@ -121,27 +121,17 @@ impl Screening { })); } - /// Check mutual contacts through the web of trust, - /// falling back to the database while it loads - fn check_wot(&mut self, cx: &mut Context) { - let trusted = { - let wot = WebOfTrust::global(cx).read(cx); - wot.is_loaded().then(|| wot.mutuals(&self.public_key)) - }; + /// Fetch mutual contacts from the database while the web of trust loads + fn fetch_fallback(&mut self, cx: &mut Context) { + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); - if let Some(contacts) = trusted { - self.mutual_contacts = contacts; - cx.notify(); - return; - } - - if self.mutuals_fallback { + if self.mutuals_fallback || persons.read(cx).wot().read(cx).is_loaded() { return; } self.mutuals_fallback = true; - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); let public_key = self.public_key; let Some(current_user) = nostr.read(cx).current_user() else { @@ -166,11 +156,9 @@ impl Screening { let contacts = task.await; this.update(cx, |this, cx| { - // The web of trust wins once its load has finished - if WebOfTrust::global(cx).read(cx).is_loaded() { + if persons.read(cx).wot().read(cx).is_loaded() { return; } - this.mutual_contacts = contacts; cx.notify(); }) @@ -292,8 +280,12 @@ impl Screening { })); } - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); + fn mutual_contacts( + &mut self, + contacts: Vec, + window: &mut Window, + cx: &mut Context, + ) { let directory = self.directory.clone(); window.open_dialog(cx, move |this, _window, _cx| { @@ -346,11 +338,23 @@ impl Render for Screening { const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; const NO_NIP05: &str = "This person has not set up their friendly address"; + let persons = PersonRegistry::global(cx); let profile = self.profile(cx); let shorten_pubkey = shorten_pubkey(self.public_key, 8); let last_active = self.last_active.map(|_| true); - let mutuals = self.mutual_contacts.len(); + + // Pull from the web of trust once loaded, else use the database fallback + let mutual_contacts = { + let wot = persons.read(cx).wot().read(cx); + if wot.is_loaded() { + wot.mutuals(&self.public_key) + } else { + self.mutual_contacts.clone() + } + }; + + let mutuals = mutual_contacts.len(); let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); v_flex() @@ -543,7 +547,11 @@ impl Render for Screening { .rounded() .disabled(mutuals == 0) .on_click(cx.listener(move |this, _, window, cx| { - this.mutual_contacts(window, cx); + this.mutual_contacts( + mutual_contacts.clone(), + window, + cx, + ); })), ), ), diff --git a/docs/web-of-trust.md b/docs/web-of-trust.md deleted file mode 100644 index 02123865..00000000 --- a/docs/web-of-trust.md +++ /dev/null @@ -1,214 +0,0 @@ -# Web of Trust — implementation plan - -A plan for scoring strangers by the user's follow graph, based on how Amethyst -implements web of trust. The goal is to make the screening dialog and the chat -requests list more useful: "3 of your contacts follow this person" instead of a -bare npub, and a way to let trusted strangers skip screening. - -## 1. How Amethyst does it - -Amethyst (vitorpamplona/amethyst) has two independent WoT layers: - -**Layer 1 — follow-graph score (`WoTService`)**, in -`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`: - -- For every pubkey `X`, the score is the number of people the user follows who - also follow `X` (friends-of-friends, computed only from kind 3 contact lists). -- The user's own follow set and each followed user's kind 3 feed the graph; the - service keeps a reverse index `target -> {followers}` and derives scores from - set sizes. -- Purely in memory, rebuilt on start; a single writer coroutine serializes - updates via an op channel and diffs each follower's list on republish, so a - changed kind 3 only touches the entries it added/removed. -- Guardrails: WoT is disabled when the user follows more than `MAX_FOLLOWS` - (2000) accounts, and each ingested contact list is capped - (`MAX_FOLLOWS_PER_EVENT`, 5000) to bound hostile publishers. -- A `ready` flag (set on first EOSE) tells the UI when scores are usable. -- Consumers sort people follows-first, then by score (`PeopleOrder.kt`) and show - a desktop badge. - -**Layer 2 — NIP-85 trust-provider index**, in -`commons/.../wot/network/` and `quartz/.../nip85TrustedAssertions/users/index/`: - -- The user names a "trust provider" (e.g. Brainstorm/GrapeRank) in a kind 10040 - event; the provider publishes a signed rank card (kind 30382) for every member - of the user's extended network (~150–300k events). -- The client downloads the whole set once, verifies every signature, and stores - a compact binary index (~6 MB: 128-bit key prefixes + rank/hops/followers - arrays) with cursor-based incremental sync (NIP-45 COUNT + NIP-77 negentropy). -- The gate is `accepted(pk) = pk == me || pk in follows || rank(pk) >= minScore` - and it drives the DM Known/New split, the "Curated" notification filter, push - filtering, and collapsing out-of-network replies. Exemptions live in one - predicate (`TrustVerdicts.kt` / `CuratedTrustFilter.kt`) so in-app and push - can't drift: zaps always pass, chats follow the DM rule, everything else is - gated on network membership. - -### What we take, what we skip - -| Amethyst piece | Take? | -|---|---| -| Friends-of-friends score, reverse index, diffing, caps, ready flag | Yes — this is the whole feature for coop | -| Follows-first ordering helper | Only if we ever list non-contacts; not needed now | -| NIP-85 provider onboarding, kind 30382 index, negentropy sync, binary files | No — massive sync + storage complexity for a DM client | -| One shared exemption predicate per surface | Yes as a principle: gate requests in one place | - -Layer 2 exists because Amethyst gates a 300k-user social graph. Coop gates -incoming DMs; the follow graph (one hop of contact lists) is enough signal and -needs no new events, relays, or storage formats. - -## 2. Design: `WebOfTrust` in the `person` crate - -One new logical component, one new file: `crates/person/src/wot.rs`, re-exported -from `crates/person/src/lib.rs` and initialized in `person::init` as a global -entity, exactly like `PersonRegistry`. - -### State - -```rust -pub struct WebOfTrust { - /// The signed-in user's contacts (latest kind 3) - follows: HashSet, - /// The current user, excluded from credit - self_key: Option, - /// Per-follower cached follow set, for diffing on republish - lists: HashMap>, - /// Reverse index: target -> contacts who follow them - reverse: HashMap>, - /// Disabled when the follow set exceeds MAX_FOLLOWS - disabled: bool, - ready: bool, - tasks, subscriptions, ... -} -``` - -API (all reads are plain methods; writes go through the entity): - -```rust -pub fn score(&self, key: &PublicKey) -> usize // reverse[key].len() -pub fn is_in_network(&self, key: &PublicKey) -> bool // follows || score >= 1 -pub fn mutuals(&self, key: &PublicKey) -> Vec // who vouches, for the dialog -pub fn is_ready(&self) -> bool -pub fn is_disabled(&self) -> bool -``` - -Events: `WoTEvent::Updated` and `WoTEvent::Ready`, emitted after hydration and -on every graph change; consumers subscribe like they do to `PersonRegistry`. - -### Data flow — reuses the existing pattern end to end - -Same shape as `PersonRegistry::new` (background listener -> flume channel -> -entity update -> `cx.emit`): - -1. **Hydrate from the local database** on creation and on - `StateEvent::SignerChanged`: - - own kind 3 (newest event, `limit(1)` like `Directory::track` and - `query_chat_rooms` do) -> `follows`; - - applied immediately so the dialog has data before the relays respond. -2. **Stay fresh with NIP-77** (`WebOfTrust::sync`, no persistent subscription): - - `client.sync(Filter::new().kind(Kind::ContactList).authors(follows + self))` - reconciles with every relay in the pool and downloads only the contact - lists the local database is missing (negentropy items come from the - database, direction is down-only); - - after each reconcile, the newest stored list per covered author is applied - to the graph and a `Ready`/`Updated` event marks what changed; - - triggers: hydrate, a 15-minute timer (Amethyst's D10 cadence), and a - follow-set change; when a reconcile grows the follow set (fresh install), - it re-syncs once more to cover the new authors. -3. **Rules ported from `WoTService`**: - - exclude the follower and `self_key` from every ingested list; - - on follow-set change, un-credit removed followers from the reverse index; - - `MAX_FOLLOWS = 2000` disables the service (clear state, emit ready); - - `MAX_TAGS_PER_LIST = 5000` caps one follower's ingested list; - - reset everything on sign-out / account switch. - -No persistence: the graph rebuilds from the LMDB cache on every start, the same -way `ChatRegistry` rebuilds rooms. No new relay events, no settings file, no new -dependencies. Relays without NIP-77 fail their reconcile (logged); the graph -then reflects whatever the database already holds. - -## 3. Consumers (in order) - -### Phase 1 — screening dialog (`crates/workspace/src/dialogs/screening.rs`) - -Status: **implemented**. The `WebOfTrust` registry lives in `crates/person/src/wot.rs` and -is initialized in `person::init`. - -Replace `check_wot`'s database query (which only sees contact lists that happen -to be cached, so "mutual contacts" is usually empty) with the registry: - -- `mutual_contacts = wot.mutuals(&public_key)` — the people **the user follows** - who follow the stranger, which is the correct WoT semantic and matches - Amethyst's score. -- The existing "Mutual contacts" dialog keeps working unchanged: it already - renders a `Vec` through a `Directory`. -- When `wot.is_disabled()` or not ready, fall back to the current query so the - dialog degrades to today's behavior. - -### Phase 2 — requests list badge - -Status: **partially implemented**. Request rooms whose members are all outside -the web of trust show a `SPAM` badge in the sidebar -(`Room::is_spam` + `WebOfTrust::is_outside`, rendered in -`crates/workspace/src/sidebar/rows.rs`; the sidebar subscribes to `WoTEvent` so -badges update when the graph loads). The profile dialog shows the score as a -"Web of Trust" field (`crates/workspace/src/dialogs/profile.rs`, other users -only). The mutual-count caption below is still open and will follow a -different flow. - -Where request rooms are rendered, show the mutual count for the sender: - -- `crates/workspace/src/sidebar/inbox.rs` (`requests_rows`) and - `crates/workspace/src/panels/greeter.rs` (`load`): one caption - `"N mutual contacts"` under the request preview, via the room's other member. -- Subscribe to `WoTEvent::Updated` where `PersonEvent` is already subscribed so - rows re-render when the graph changes. - -### Phase 3 — opt-in auto-trust (behind a setting) - -Status: **implemented** (Phase 2 skipped for now; it will follow a different -flow). - -The payoff: strangers vouched for by the user's contacts can skip screening. - -- `pub wot_auto_trust: bool` in `setting_accessors!` in - `crates/settings/src/lib.rs` (enabled out of the box: `Default` is `true` and - a `#[serde(default = "default_true")]` fallback covers settings files saved - before the field existed) and a "Web of trust" toggle in - the preferences screen (`crates/workspace/src/dialogs/settings.rs`). -- The rule lives in one predicate — `Room::auto_trusted(me, cx)` in - `crates/chat/src/room.rs`: the setting is on and every member other than the - current user is inside the web of trust (`is_in_network`, so followed - senders always pass and strangers need at least one mutual contact). - It's applied at every point a room's kind is decided so the states can't - drift: `ChatRegistry::add_room` (incoming first message), - `ChatRegistry::new_message` (live promotion next to the existing - I-sent-it rule), and `ChatRegistry::extend_rooms` (rooms loaded from the - database, so the split survives a restart). - -Deliberately not planned: sorting contacts (the new-chat dialog lists only -follows already), notification feeds (coop has none), muting/spam scoring. - -## 4. Non-goals - -- NIP-85 trust providers (kind 10040/30382), Brainstorm onboarding, rank - thresholds — revisit only if coop ever needs a network-scale gate. -- Persisting the graph or multi-hop (3+) scores — kind 3s are one LMDB query - away on restart, and negentropy keeps them current. - -## 5. Testing - -- Unit tests for the pure graph logic in `wot.rs` (no `cx` needed): seeding, - per-follower diffing, follow-set removal un-crediting, self/follower - exclusion, both caps and the disabled transition. -- One GPUI test for hydration ordering (newest kind 3 wins) using - `TestAppContext`; use `cx.background_executor().timer(...)` for any waits per - the project guidelines. - -## 6. References - -- `WoTService.kt` — the model this plan ports: - https://github.com/vitorpamplona/amethyst/blob/main/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt -- `PeopleOrder.kt`, `CuratedTrustFilter.kt`, `TrustVerdicts.kt` — consumers and - the shared-predicate pattern. -- Design doc for Amethyst's NIP-85 index (the layer we skip, with the reasoning - and measurements): `commons/plans/2026-10-07-wot-network-index.md` on main.