Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/wot-graph-service.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"nostream": minor
---

feat: add a Web of Trust graph service that tracks NIP-02 follow distance from an operator-configured seed pubkey

Adds a `WotGraphService` that builds a trust graph rooted at `wot.seedPubkey`, updated in real
time as kind-3 contact list events are ingested, with configurable depth (`wot.maxDepth`) and a
minimum-followers threshold for 2+ hop trust (`wot.minimumFollowers`). Exposes `getDistance()` and
`isTrusted()` for other parts of the relay to query. Disabled by default (`wot.enabled: false`).
5 changes: 5 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. |
| workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. |
| wot.enabled | Enables the Web of Trust graph, rooted at `wot.seedPubkey`, built from NIP-02 contact lists. Defaults to false. |
| wot.maxDepth | How many hops out from `wot.seedPubkey` the trust graph extends. Direct follows are distance 1. Defaults to 2. |
| wot.minimumFollowers | Minimum number of already-trusted accounts that must follow a pubkey before it enters the graph at 2+ hops. Direct follows are always trusted. Defaults to 1. |
| wot.refreshIntervalHours | Hours between full consistency rebuilds, on top of the real-time updates applied as kind-3 events are ingested. Defaults to 24. |
| wot.seedPubkey | The relay owner's pubkey in hex. Root of the trust graph. Required when `wot.enabled` is true. |
12 changes: 8 additions & 4 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,19 @@ nip66:
targets: []
dnsCacheTtlSeconds: 300
wot:
# Web of Trust filtering. When enabled, only events from pubkeys within
# the relay owner's 2-hop follow graph are accepted.
# Web of Trust graph. When enabled, the relay builds a trust graph rooted
# at seedPubkey from NIP-02 contact lists, updated in real time as kind-3
# events arrive.
enabled: false
# The relay owner's pubkey in hex. This is the root of the trust graph.
# Required when enabled is true.
seedPubkey: ""
# A pubkey must be followed by at least this many 1-hop accounts to be trusted.
# A pubkey must be followed by at least this many already-trusted accounts
# to enter the graph at 2+ hops. Direct (1-hop) follows are always trusted.
minimumFollowers: 1
# Hours between full trust graph rebuilds.
# How many hops out from seedPubkey the trust graph extends.
maxDepth: 2
# Hours between full consistency rebuilds, on top of real-time updates.
refreshIntervalHours: 24
network:
maxPayloadSize: 524288
Expand Down
3 changes: 3 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ export interface ICacheAdapter {
getHKey(key: string, field: string): Promise<string>
setHKey(key: string, fields: Record<string, string>): Promise<boolean>

addToSet(key: string, members: string[]): Promise<number>
getSetMembers(key: string): Promise<string[]>

eval(script: string, keys: string[], args: string[]): Promise<unknown>
}
13 changes: 13 additions & 0 deletions src/@types/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ export interface IMaintenanceService {
clearOldEvents(): Promise<void>
}

export interface IWotGraphService {
/** True once the trust graph has completed at least one build. */
isReady(): boolean
/**
* Distance (in hops) from the configured seed pubkey, or undefined if the
* pubkey is outside the configured trust depth (or WoT is disabled).
*/
getDistance(pubkey: Pubkey): Promise<number | undefined>
isTrusted(pubkey: Pubkey): Promise<boolean>
/** Applies a pubkey's current NIP-02 follow list to the graph. */
updateFollowList(pubkey: Pubkey, follows: Pubkey[]): Promise<void>
}

export interface IPaymentsService {
getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise<Partial<Invoice>>
createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise<Invoice>
Expand Down
16 changes: 13 additions & 3 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,22 @@ export interface WoTSettings {
*/
seedPubkey: Pubkey
/**
* Minimum number of 1-hop follows a pubkey must have to enter the trust filter.
* Defaults to 1.
* Minimum number of already-trusted accounts that must follow a pubkey
* before it enters the trust graph at 2+ hops. Direct (1-hop) follows of
* the seed are always trusted regardless of this value. Defaults to 1.
*/
minimumFollowers: number
/**
* How many hours between full trust graph rebuilds.
* How many hops out from the seed pubkey the trust graph extends.
* Direct follows are distance 1, follows-of-follows are distance 2, etc.
* Defaults to 2.
*/
maxDepth: number
/**
* How often (in hours) the graph does a full consistency rebuild from
* stored contact-list events, on top of the real-time updates applied as
* kind-3 events are ingested. Not the primary update mechanism — just a
* periodic safety net (e.g. after a restart with a cold Redis cache).
* Defaults to 24.
*/
refreshIntervalHours: number
Expand Down
12 changes: 12 additions & 0 deletions src/adapters/redis-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ export class RedisAdapter implements ICacheAdapter {
return (await this.client.hSet(key, fields)) >= 0
}

public async addToSet(key: string, members: string[]): Promise<number> {
await this.connection
logger('add %o to set %s', members, key)
return this.client.sAdd(key, members)
}

public async getSetMembers(key: string): Promise<string[]> {
await this.connection
logger('get members of set %s', key)
return this.client.sMembers(key)
}

public async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
await this.connection
if (!this.scriptShas.has(script)) {
Expand Down
12 changes: 12 additions & 0 deletions src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
import { IDvmJobRepository, IEventRepository, IInviteCodeRepository, IUserRepository } from '../@types/repositories'
import {
isContactListEvent,
isDeleteEvent,
isDvmJobRequestEvent,
isEphemeralEvent,
Expand All @@ -13,6 +14,7 @@ import {
} from '../utils/event'
import { isNip43JoinRequest, isNip43LeaveRequest } from '../utils/nip43'
import { isRelayListEvent } from '../utils/nip65'
import { ContactListEventStrategy } from '../handlers/event-strategies/contact-list-event-strategy'
import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy'
import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy'
import { DvmJobRequestEventStrategy } from '../handlers/event-strategies/dvm-job-request-event-strategy'
Expand All @@ -29,6 +31,7 @@ import { ReplaceableEventStrategy } from '../handlers/event-strategies/replaceab
import { Settings } from '../@types/settings'
import { TimestampEventStrategy } from '../handlers/event-strategies/timestamp-event-strategy'
import { VanishEventStrategy } from '../handlers/event-strategies/vanish-event-strategy'
import { wotGraphServiceFactory } from './wot-graph-service-factory'

export const eventStrategyFactory =
(
Expand All @@ -48,6 +51,15 @@ export const eventStrategyFactory =
return new GroupEventStrategy(adapter, eventRepository)
} else if (isOpenTimestampsEvent(event)) {
return new TimestampEventStrategy(adapter, eventRepository)
// NIP-02: contact lists are replaceable (handled below), but need the
// extra wot-graph side effect, so they're intercepted before the
// generic replaceable-event branch.
} else if (isContactListEvent(event)) {
return new ContactListEventStrategy(
adapter,
eventRepository,
wotGraphServiceFactory(cache, eventRepository, settings),
)
} else if (isRelayListEvent(event) || isReplaceableEvent(event)) {
return new ReplaceableEventStrategy(adapter, eventRepository)
// NIP-43: Join/Leave requests MUST be checked before the generic ephemeral
Expand Down
19 changes: 19 additions & 0 deletions src/factories/wot-graph-service-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ICacheAdapter } from '../@types/adapters'
import { IEventRepository } from '../@types/repositories'
import { IWotGraphService } from '../@types/services'
import { Settings } from '../@types/settings'
import { WotGraphService } from '../services/wot-graph-service'

let instance: IWotGraphService | undefined

export const wotGraphServiceFactory = (
cache: ICacheAdapter,
eventRepository: IEventRepository,
settings: () => Settings,
): IWotGraphService => {
if (!instance) {
instance = new WotGraphService(cache, eventRepository, settings)
}

return instance
}
60 changes: 60 additions & 0 deletions src/handlers/event-strategies/contact-list-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createEventCommandResult } from '../../telemetry/event-metrics'
import { createLogger } from '../../factories/logger-factory'
import { Event } from '../../@types/event'
import { EventTags } from '../../constants/base'
import { IEventRepository } from '../../@types/repositories'
import { IEventStrategy } from '../../@types/message-handlers'
import { IWebSocketAdapter } from '../../@types/adapters'
import { IWotGraphService } from '../../@types/services'
import { WebSocketAdapterEvent } from '../../constants/adapter'

const logger = createLogger('contact-list-event-strategy')

export class ContactListEventStrategy implements IEventStrategy<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
private readonly wotGraphService: IWotGraphService,
) {}

public async execute(event: Event): Promise<void> {
logger('received contact list event: %o', event)
try {
const count = await this.eventRepository.upsert(event)
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createEventCommandResult(event.id, true, count ? '' : 'duplicate:'),
)
if (!count) {
return
}

this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event)

try {
const follows = event.tags.filter((tag) => tag[0] === EventTags.Pubkey).map((tag) => tag[1])
await this.wotGraphService.updateFollowList(event.pubkey, follows)
} catch (error) {
// WoT graph updates are best-effort: the contact list itself is
// already stored and broadcast correctly, so a graph-update failure
// here must not surface as a rejection of a valid event.
logger.error('unable to update wot graph for pubkey %s: %o', event.pubkey, error)
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message.endsWith('duplicate key value violates unique constraint "events_event_id_unique"')) {
this.webSocket.emit(
WebSocketAdapterEvent.Message,
createEventCommandResult(event.id, false, 'rejected: event already exists'),
)
return
}

this.webSocket.emit(
WebSocketAdapterEvent.Message,
createEventCommandResult(event.id, false, `error: ${error.message}`),
)
}
}
}
}
157 changes: 157 additions & 0 deletions src/services/wot-graph-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createLogger } from '../factories/logger-factory'
import { EventKinds, EventTags } from '../constants/base'
import { ICacheAdapter } from '../@types/adapters'
import { IEventRepository } from '../@types/repositories'
import { IWotGraphService } from '../@types/services'
import { Pubkey, Tag } from '../@types/base'
import { Settings } from '../@types/settings'
import { toNostrEvent } from '../utils/event'

const logger = createLogger('wot-graph-service')

const followSetKey = (pubkey: Pubkey): string => `wot:follows:${pubkey}`

const extractFollowedPubkeys = (tags: Tag[]): Pubkey[] =>
tags
.filter((tag) => tag[0] === EventTags.Pubkey && typeof tag[1] === 'string' && tag[1].length > 0)
.map((tag) => tag[1])

export class WotGraphService implements IWotGraphService {
private distances: Map<Pubkey, number> = new Map()

private ready = false

private building: Promise<void> | undefined

public constructor(
private readonly cache: ICacheAdapter,
private readonly eventRepository: IEventRepository,
private readonly settings: () => Settings,
) {}

public isReady(): boolean {
return this.ready
}

public async getDistance(pubkey: Pubkey): Promise<number | undefined> {
const wot = this.settings().wot
if (!wot?.enabled || !wot.seedPubkey) {
return undefined
}

if (pubkey === wot.seedPubkey) {
return 0
}

await this.ensureBuilt()

return this.distances.get(pubkey)
}

public async isTrusted(pubkey: Pubkey): Promise<boolean> {
return typeof (await this.getDistance(pubkey)) === 'number'
}

public async updateFollowList(pubkey: Pubkey, follows: Pubkey[]): Promise<void> {
const wot = this.settings().wot
if (!wot?.enabled) {
return
}

await this.cache.deleteKey(followSetKey(pubkey))
if (follows.length) {
await this.cache.addToSet(followSetKey(pubkey), follows)
}

// A pubkey outside the current trust graph publishing a new follow list
// can't change anyone's distance from the seed, so only rebuild when the
// change could actually matter.
if (pubkey === wot.seedPubkey || this.distances.has(pubkey)) {
await this.rebuild()
}
}

private async ensureBuilt(): Promise<void> {
if (this.ready) {
return
}
if (!this.building) {
this.building = this.rebuild()
}
await this.building
}

private async rebuild(): Promise<void> {
const wot = this.settings().wot
if (!wot?.enabled || !wot.seedPubkey) {
this.distances = new Map()
this.ready = true
this.building = undefined
return
}

const maxDepth = wot.maxDepth ?? 2
const minimumFollowers = wot.minimumFollowers ?? 1

const distances = new Map<Pubkey, number>()
let frontier = [wot.seedPubkey]

for (let depth = 1; depth <= maxDepth && frontier.length; depth++) {
const followerCounts = new Map<Pubkey, number>()

for (const pubkey of frontier) {
const follows = await this.getFollows(pubkey)
for (const followed of follows) {
if (followed === wot.seedPubkey || distances.has(followed)) {
continue
}
followerCounts.set(followed, (followerCounts.get(followed) ?? 0) + 1)
}
}

// Direct follows of the seed are always trusted; deeper hops need at
// least `minimumFollowers` already-trusted accounts pointing at them.
const threshold = depth === 1 ? 1 : minimumFollowers
const nextFrontier: Pubkey[] = []
for (const [candidate, count] of followerCounts) {
if (count >= threshold) {
distances.set(candidate, depth)
nextFrontier.push(candidate)
}
}

frontier = nextFrontier
}

this.distances = distances
this.ready = true
this.building = undefined
logger('rebuilt wot graph: %d pubkeys within %d hops of %s', distances.size, maxDepth, wot.seedPubkey)
}

private async getFollows(pubkey: Pubkey): Promise<Pubkey[]> {
// Redis is the fast path once a pubkey's follow list has gone through
// updateFollowList(); a pubkey that genuinely follows no one is
// indistinguishable from an uncached one here and falls back to the DB
// on every rebuild -- acceptable for a first pass, since that's the rare
// case in a real follow graph.
const cached = await this.cache.getSetMembers(followSetKey(pubkey))
if (cached.length) {
return cached
}

const [event] = await this.eventRepository.findByFilters([
{ kinds: [EventKinds.CONTACT_LIST], authors: [pubkey], limit: 1 },
])

if (!event) {
return []
}

const follows = extractFollowedPubkeys(toNostrEvent(event).tags)
if (follows.length) {
await this.cache.addToSet(followSetKey(pubkey), follows)
}
return follows
}
}
Loading
Loading