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
11 changes: 11 additions & 0 deletions .changeset/wot-aware-pow-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"nostream": minor
---

feat: wire the WoT graph into adaptive PoW difficulty

Adds `limits.event.pow.wotThresholds`, letting operators reduce (or bypass) the eventId PoW
requirement for pubkeys within their configured WoT distance. A direct follow can post instantly
under load while an unknown pubkey pays the full adaptive difficulty. Disabled by default (no
thresholds configured); requires `wot.enabled` to have any effect, since a pubkey's distance is
otherwise always unknown.
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.event.pow.floorBits | Minimum adaptive PoW difficulty, used at or below approximately `targetEventsPerSecond`. With the default `floorBits: 0`, the load signal costs an attacker nothing to drive; set a non-zero floor if the gate should cost something even under light load. |
| limits.event.pow.periodMs | EWMA half-life (ms) used to smooth the observed event rate. |
| limits.event.pow.targetEventsPerSecond | Event-rate threshold (in real events/sec) above which the adaptive difficulty starts climbing toward `ceilingBits`. |
| limits.event.pow.wotThresholds[].difficultyFactor | Fraction of the computed adaptive difficulty required at this threshold's `maxDistance`: 0 bypasses eventId PoW entirely, 1 requires the full computed difficulty. |
| limits.event.pow.wotThresholds[].maxDistance | Reporters at or under this WoT distance (from `wot.seedPubkey`) get this threshold's `difficultyFactor`. The eligible threshold with the smallest `maxDistance` applies. Requires `wot.enabled`; a pubkey outside the trust graph always pays the full computed difficulty. |
| limits.event.pubkey.blacklist | List of public keys to always reject. Public keys in this list will not be able to post to this relay. |
| limits.event.pubkey.minLeadingZeroBits | Leading zero bits required on the public key of incoming events for proof of work. Defaults to zero. Disabled when set to zero. Always enforced regardless of `limits.event.pow.enabled` -- adaptive PoW never applies to the pubkey check (see `limits.event.pow.enabled`). |
| limits.event.pubkey.whitelist | List of public keys to always allow. Only public keys in this list will be able to post to this relay. Use for private relays. |
Expand Down
9 changes: 9 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ limits:
ceilingBits: 24
targetEventsPerSecond: 50
periodMs: 60000
# Optional WoT-distance-based reductions on top of the computed adaptive
# difficulty. Requires wot.enabled. Example: direct follows (distance 1)
# bypass eventId PoW entirely, distance-2 pubkeys need half the computed
# difficulty, anyone farther (or outside the trust graph) pays it in full.
# - maxDistance: 1
# difficultyFactor: 0
# - maxDistance: 2
# difficultyFactor: 0.5
wotThresholds: []
kind:
whitelist: []
blacklist: []
Expand Down
20 changes: 20 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ export interface EventRetentionLimits {
pubkey?: EventRetentionPubkeyLimits
}

export interface WotPowThreshold {
/** Reporters at or under this WoT distance get this threshold's reduction. */
maxDistance: number
/**
* Fraction of the computed adaptive difficulty required at this distance:
* 0 bypasses eventId PoW entirely, 1 requires the full computed difficulty,
* fractional values scale linearly in between.
*/
difficultyFactor: number
}

export interface AdaptivePowSettings {
/** Enables load-aware difficulty scaling on the eventId check, replacing eventId.minLeadingZeroBits while enabled. Does not affect the pubkey check -- pubkey.minLeadingZeroBits stays a static, non-adaptive knob. Defaults to false. */
enabled: boolean
Expand All @@ -103,6 +114,15 @@ export interface AdaptivePowSettings {
targetEventsPerSecond: number
/** EWMA half-life in ms used to smooth the observed event rate. */
periodMs: number
/**
* Optional WoT-distance-based reductions layered on top of the computed
* adaptive difficulty. The eligible threshold with the smallest maxDistance
* applies; a pubkey outside the trust graph (distance undefined) or beyond
* every threshold's maxDistance gets the full computed difficulty. Requires
* wot.enabled -- otherwise every distance lookup is undefined and this has
* no effect.
*/
wotThresholds?: WotPowThreshold[]
}

export interface EventLimits {
Expand Down
2 changes: 2 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RedisAdapter } from '../adapters/redis-adapter'
import { rateLimiterFactory } from './rate-limiter-factory'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
import { wotGraphServiceFactory } from './wot-graph-service-factory'

let cacheAdapter: ICacheAdapter | undefined = undefined
const getCache = (): ICacheAdapter => {
Expand Down Expand Up @@ -53,6 +54,7 @@ export const messageHandlerFactory =
nip05VerificationRepository,
getCache(),
rateLimiterFactory,
wotGraphServiceFactory(getCache(), eventRepository, createSettings),
)
}
case MessageType.REQ:
Expand Down
16 changes: 13 additions & 3 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getCurrentDifficulty as getAdaptivePowDifficulty,
recordEvent as recordAdaptivePowEvent,
} from '../utils/adaptive-pow'
import { applyWotPowPolicy } from '../utils/wot-pow-policy'
import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base'
import { attemptValidation } from '../utils/validation'
import { eventSchema } from '../schemas/event-schema'
Expand Down Expand Up @@ -43,6 +44,7 @@ import { ICacheAdapter } from '../@types/adapters'
import { IncomingEventMessage } from '../@types/messages'
import { IRateLimiter } from '../@types/utils'
import { IWebSocketAdapter } from '../@types/adapters'
import { IWotGraphService } from '../@types/services'
import { Nip05Verification } from '../@types/nip05'
import { WebSocketAdapterEvent } from '../constants/adapter'

Expand All @@ -58,6 +60,7 @@ export class EventMessageHandler implements IMessageHandler {
private readonly nip05VerificationRepository: INip05VerificationRepository,
private readonly cache: ICacheAdapter,
private readonly rateLimiter: Factory<IRateLimiter>,
private readonly wotGraphService: IWotGraphService,
) {}

public async handleMessage(message: IncomingEventMessage): Promise<void> {
Expand Down Expand Up @@ -89,7 +92,7 @@ export class EventMessageHandler implements IMessageHandler {
return
}

reason = this.canAcceptEvent(event)
reason = await this.canAcceptEvent(event)
if (reason) {
logger('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, reason))
Expand Down Expand Up @@ -167,7 +170,7 @@ export class EventMessageHandler implements IMessageHandler {
return getPublicKey(relayPrivkey)
}

protected canAcceptEvent(event: Event): string | undefined {
protected async canAcceptEvent(event: Event): Promise<string | undefined> {
if (this.getRelayPublicKey() === event.pubkey) {
return
}
Expand Down Expand Up @@ -217,7 +220,14 @@ export class EventMessageHandler implements IMessageHandler {
// The static pubkey.minLeadingZeroBits knob is left untouched regardless of
// pow.enabled -- per maintainer direction on PR #756.
if (limits.pow?.enabled) {
const requiredBits = getAdaptivePowDifficulty(limits.pow)
const computedDifficulty = getAdaptivePowDifficulty(limits.pow)
// Only consult the WoT graph when thresholds are actually configured --
// a distance lookup is unnecessary work otherwise, and skipping it also
// means wot.enabled=false relays never pay for it.
const distance = limits.pow.wotThresholds?.length
? await this.wotGraphService.getDistance(event.pubkey)
: undefined
const requiredBits = applyWotPowPolicy(computedDifficulty, distance, limits.pow.wotThresholds)

const pow = getEventProofOfWork(event.id)
if (pow < requiredBits) {
Expand Down
16 changes: 16 additions & 0 deletions src/utils/settings-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,22 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => {
message: 'targetEventsPerSecond must be greater than 0',
})
}
if (Array.isArray(pow.wotThresholds)) {
pow.wotThresholds.forEach((threshold, index) => {
if (!(threshold.maxDistance >= 0)) {
issues.push({
path: `limits.event.pow.wotThresholds[${index}].maxDistance`,
message: 'maxDistance must be >= 0',
})
}
if (!(threshold.difficultyFactor >= 0) || !(threshold.difficultyFactor <= 1)) {
issues.push({
path: `limits.event.pow.wotThresholds[${index}].difficultyFactor`,
message: 'difficultyFactor must be between 0 and 1',
})
}
})
}
}

validateShape(loadDefaults(), settings, [], issues)
Expand Down
26 changes: 26 additions & 0 deletions src/utils/wot-pow-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { WotPowThreshold } from '../@types/settings'

// A pubkey outside the trust graph (distance undefined) or with no eligible
// threshold gets the full computed difficulty unchanged -- reductions are an
// earned benefit of being inside the operator's trust circle, never a default.
export const applyWotPowPolicy = (
computedDifficulty: number,
distance: number | undefined,
thresholds: WotPowThreshold[] | undefined,
): number => {
if (!thresholds?.length || distance === undefined) {
return computedDifficulty
}

const eligible = thresholds.filter((threshold) => distance <= threshold.maxDistance)
if (!eligible.length) {
return computedDifficulty
}

// The closest (smallest maxDistance) eligible threshold wins -- e.g. with
// thresholds at maxDistance 1 and 2, a distance-1 pubkey gets the tighter
// (usually more generous) distance-1 reduction, not the distance-2 one.
const closest = eligible.reduce((best, threshold) => (threshold.maxDistance < best.maxDistance ? threshold : best))

return Math.ceil(computedDifficulty * closest.difficultyFactor)
}
Loading
Loading