Skip to content
Merged
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 @@ -261,3 +261,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 | Reserved for a future periodic full rebuild; not yet read by any code (real-time kind-3 ingestion already keeps the graph current). 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 @@ -123,15 +123,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
# Reserved for a future periodic rebuild; not yet read by any code.
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
15 changes: 12 additions & 3 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,12 +358,21 @@ 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
/**
* Reserved for a future periodic full consistency rebuild, on top of the
* real-time updates already applied as kind-3 events are ingested. Not
* yet consumed by any code — no background worker reads this field.
* 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 { isNip43InviteRequest, 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 @@ -30,6 +32,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 @@ -49,6 +52,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/Invite requests MUST be checked before the generic
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}`),
)
}
}
}
}
Loading
Loading