Files
coop/docs/web-of-trust.md
T
2026-10-20 08:44:32 +07:00

9.3 KiB
Raw Blame History

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

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):

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, authors sorted by Reverse(created_at) like Directory::track does) -> follows;
    • one batch query Filter::new().kind(Kind::ContactList).authors(follows) -> dedupe per author keeping the newest -> seed lists/reverse.
  2. Stay fresh: one persistent subscription with a dedicated SubscriptionId (add WOT_CONTACTS beside DEVICE_GIFTWRAP/USER_GIFTWRAP in state::constants), filter kind(Kind::ContactList).authors(follows). The background listener accepts any kind 3 whose author is in follows (don't couple to subscription ids), diffs it against the cached list, and updates the reverse index. Re-issue the subscription when the user's own contact list changes.
  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.

3. Consumers (in order)

Phase 1 — screening dialog (crates/workspace/src/dialogs/screening.rs)

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

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)

The payoff: strangers vouched for by the user's contacts can skip screening.

  • Add pub wot_auto_trust: bool to setting_accessors! in crates/settings/src/lib.rs (default false) and a toggle in the existing preferences screen (crates/workspace/src/dialogs/settings.rs).

  • When enabled, apply it in one place — ChatRegistry::new_message in crates/chat/src/lib.rs, next to the existing promotion rule — so in-app and any future consumer can't drift (Amethyst's lesson from TrustVerdicts):

    if this.kind == RoomKind::Request
        && (message.rumor.pubkey == public_key
            || (AppSettings::get_wot_auto_trust(cx) && wot.is_in_network(&message.rumor.pubkey)))
    {
        this.set_ongoing(cx);
    }
    

    Group requests stay conservative: every member must be in the network (or already a contact) to promote, mirroring how is_contact works today.

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.
  • Background jobs / WorkManager equivalents — hydration is two local queries.

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