feat: add support for basic web of trust #65

Merged
reya merged 9 commits from feat/wot into master 2026-10-20 13:43:39 +00:00
6 changed files with 70 additions and 314 deletions
Showing only changes of commit d64da17c5c - Show all commits
+3 -3
View File
@@ -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))
}
+14 -1
View File
@@ -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<PersonRegistry>);
@@ -63,6 +62,8 @@ enum Dispatch {
pub struct PersonRegistry {
/// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>,
/// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>,
tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>,
@@ -83,6 +84,7 @@ impl PersonRegistry {
fn new(cx: &mut Context<Self>) -> 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::<Dispatch>(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<WebOfTrust> {
self.wot.clone()
}
/// Handle nostr notifications
async fn handle_notifications(client: &Client, tx: &flume::Sender<Dispatch>) {
let mut notifications = client.notifications();
+14 -65
View File
@@ -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<WebOfTrust>);
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<WoTEvent> 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<Task<()>>,
/// 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<Self> {
cx.global::<GlobalWebOfTrust>().0.clone()
}
/// Set the global web of trust instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalWebOfTrust(state));
}
/// Create a new web of trust instance
pub(crate) fn new(cx: &mut Context<Self>) -> 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<Self>) {
pub(crate) fn hydrate(&mut self, cx: &mut Context<Self>) {
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<Option<Event>> = 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<Self>) {
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
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![],
}
}
+2 -2
View File
@@ -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| {
+37 -29
View File
@@ -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<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// 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<Directory>,
@@ -43,7 +43,7 @@ pub struct Screening {
impl Screening {
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
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<Self>) {
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<Self>) {
let contacts = self.mutual_contacts.clone();
fn mutual_contacts(
&mut self,
contacts: Vec<PublicKey>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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,
);
})),
),
),
-214
View File
@@ -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<PublicKey>,
/// The current user, excluded from credit
self_key: Option<PublicKey>,
/// Per-follower cached follow set, for diffing on republish
lists: HashMap<PublicKey, HashSet<PublicKey>>,
/// Reverse index: target -> contacts who follow them
reverse: HashMap<PublicKey, HashSet<PublicKey>>,
/// 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<PublicKey> // 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<PublicKey>` 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.