Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/adaptive-pow-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"nostream": minor
---

feat: add relay-load-aware adaptive PoW difficulty (NIP-13)

Adds `limits.event.pow` settings that scale the required proof-of-work difficulty between a
configured floor and ceiling based on the observed event rate, in place of the existing static
`eventId.minLeadingZeroBits` value (the static `pubkey.minLeadingZeroBits` check is unaffected and
still enforced independently). The event rate is tracked per worker process with the same EWMA
shape already used by the relay's rate limiter. Disabled by default
(`limits.event.pow.enabled: false`), so existing static PoW configuration is unaffected unless
explicitly opted in.
9 changes: 7 additions & 2 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,16 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| limits.event.content[].maxLength | Maximum length of `content`. Defaults to 1 MB. Disabled when set to zero. |
| limits.event.createdAt.maxNegativeDelta | Maximum number of seconds an event's `created_at` can be in the past. Defaults to zero. Disabled when set to zero. |
| limits.event.createdAt.maxPositiveDelta | Maximum number of seconds an event's `created_at` can be in the future. Defaults to 900 (15 minutes). Disabled when set to zero. |
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. |
| limits.event.eventId.minLeadingZeroBits | Leading zero bits required on every incoming event for proof of work. Defaults to zero. Disabled when set to zero. Ignored on the client path while `limits.event.pow.enabled` is true (mirrored events from `static-mirroring-worker.ts` still enforce this static value). |
| limits.event.kind.blacklist | List of event kinds to always reject. Leave empty to allow any. |
| limits.event.kind.whitelist | List of event kinds to always allow. Leave empty to allow any. |
| limits.event.pow.ceilingBits | Maximum adaptive PoW difficulty, reached at approximately 2x `targetEventsPerSecond` and beyond. |
| limits.event.pow.enabled | Enables load-aware PoW difficulty scaling on the eventId check only, in place of the static `eventId.minLeadingZeroBits` value. Does not affect `pubkey.minLeadingZeroBits`, which stays a static, non-adaptive knob regardless of this setting -- a pubkey requirement is a one-time offline identity cost, not a per-event load signal. Defaults to false. |
| 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.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. |
| 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. |
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
| limits.event.rateLimits[].period | Rate limiting period in milliseconds. For `sliding_window`: the time window during which requests are counted. For `ewma`: the half-life of the exponential decay — shorter values forget bursts faster, longer values are stricter on bursty clients. |
Expand Down
10 changes: 10 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,16 @@ limits:
whitelist: []
eventId:
minLeadingZeroBits: 0
# Adaptive PoW: scales the required difficulty on the eventId check with
# observed relay load instead of using a fixed minLeadingZeroBits value.
# Does not affect the pubkey check above -- that stays static regardless.
# Disabled by default.
pow:
enabled: false
floorBits: 0
ceilingBits: 24
targetEventsPerSecond: 50
periodMs: 60000
kind:
whitelist: []
blacklist: []
Expand Down
14 changes: 14 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ export interface EventRetentionLimits {
pubkey?: EventRetentionPubkeyLimits
}

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
/** Minimum required difficulty, used at or below approximately targetEventsPerSecond. */
floorBits: number
/** Maximum required difficulty, reached at approximately 2x targetEventsPerSecond and beyond. */
ceilingBits: number
/** Event-rate threshold, in real events/sec, above which difficulty starts climbing toward ceilingBits. */
targetEventsPerSecond: number
/** EWMA half-life in ms used to smooth the observed event rate. */
periodMs: number
}

export interface EventLimits {
eventId?: EventIdLimits
pubkey?: PubkeyLimits
Expand All @@ -101,6 +114,7 @@ export interface EventLimits {
rateLimits?: EventRateLimit[]
whitelists?: EventWhitelists
retention?: EventRetentionLimits
pow?: AdaptivePowSettings
}

export interface ClientSubscriptionLimits {
Expand Down
30 changes: 29 additions & 1 deletion src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
getCurrentDifficulty as getAdaptivePowDifficulty,
recordEvent as recordAdaptivePowEvent,
} from '../utils/adaptive-pow'
import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base'
import { attemptValidation } from '../utils/validation'
import { eventSchema } from '../schemas/event-schema'
Expand Down Expand Up @@ -127,6 +131,18 @@ export class EventMessageHandler implements IMessageHandler {
return
}

// Recorded here, not inside canAcceptEvent's PoW branch: only events that
// clear every admission check up to this point (PoW, blacklist, auth,
// NIP-05, ...) should count toward the load signal. Recording earlier
// would let cheap, easily-rejected spam (e.g. from rotating pubkeys) push
// the difficulty to ceiling for everyone without the attacker ever doing
// any real work. Note: a duplicate/no-op write still counts here, since
// dedup is decided later inside the event strategy's own execute().
const powSettings = this.settings().limits?.event?.pow
if (powSettings?.enabled) {
recordAdaptivePowEvent(powSettings.periodMs)
}

const strategy = this.strategyFactory([event, this.webSocket])

if (typeof strategy?.execute !== 'function') {
Expand Down Expand Up @@ -195,7 +211,19 @@ export class EventMessageHandler implements IMessageHandler {
return `rejected: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`
}

if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) {
// Adaptive PoW applies to the eventId check only: a pubkey requirement is a
// one-time, offline identity cost, not a per-event load signal, so it can't
// respond to relay load the way an event id's mined-per-submission pow can.
// The static pubkey.minLeadingZeroBits knob is left untouched regardless of
// pow.enabled -- per maintainer direction on PR #756.
if (limits.pow?.enabled) {
Comment thread
Priyanshubhartistm marked this conversation as resolved.
const requiredBits = getAdaptivePowDifficulty(limits.pow)

const pow = getEventProofOfWork(event.id)
if (pow < requiredBits) {
return `pow: difficulty ${pow}<${requiredBits}`
}
} else if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) {
const pow = getEventProofOfWork(event.id)
if (pow < limits.eventId.minLeadingZeroBits) {
return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`
Expand Down
8 changes: 7 additions & 1 deletion src/handlers/request-handlers/root-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
settings.nip42?.authRequired === true ||
(eventLimits?.eventId?.minLeadingZeroBits ?? 0) > 0 ||
(eventLimits?.pubkey?.minLeadingZeroBits ?? 0) > 0 ||
eventLimits?.pow?.enabled === true ||
(eventLimits?.pubkey?.whitelist?.length ?? 0) > 0 ||
(eventLimits?.pubkey?.blacklist?.length ?? 0) > 0 ||
(eventLimits?.kind?.whitelist?.length ?? 0) > 0 ||
Expand Down Expand Up @@ -111,7 +112,12 @@ export const rootRequestHandler = (request: Request, response: Response, next: N
max_content_length: Array.isArray(content)
? content[0].maxLength // best guess since we have per-kind limits
: content?.maxLength,
min_pow_difficulty: eventLimits?.eventId?.minLeadingZeroBits,
// When adaptive PoW is enabled it replaces the static minLeadingZeroBits checks
// entirely, so advertise its floor -- the guaranteed minimum; the live requirement
// can be higher under load, but there's no static number to promise instead.
min_pow_difficulty: eventLimits?.pow?.enabled
? eventLimits.pow.floorBits
: eventLimits?.eventId?.minLeadingZeroBits,
// NIP-11: auth_required means AUTH before any action. We only gate publishes
// via nip42.authRequired (advertised as restricted_writes instead).
auth_required: false,
Expand Down
42 changes: 42 additions & 0 deletions src/utils/adaptive-pow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AdaptivePowSettings } from '../@types/settings'
import { calculateEWMA } from './ewma-rate-limiter'

// Per-worker in-process state: adaptive PoW is a soft anti-spam gate, not a
// hard cross-worker limit, so there's no need to pay a Redis round-trip on
// every single event just to read a difficulty threshold.
let rate = 0
// 0, not Date.now(): the first recordEvent() call computes a huge deltaT
// against it, which decays rOld (0) to effectively nothing before adding
// the new hit -- exactly "never recorded before" without a special case.
let lastEventAt = 0

export const recordEvent = (periodMs: number, now: number = Date.now()): void => {
rate = calculateEWMA(rate, Math.max(0, now - lastEventAt), periodMs, 1)
lastEventAt = now
}

export const getCurrentRate = (): number => rate

// calculateEWMA's `rate` is a recency-weighted event count, not a per-second
// rate: at a steady R events/sec it converges to R * periodMs/1000/ln(2) --
// about 86.6x R at the default 60s half-life. Divide back out by that same
// factor before comparing against targetEventsPerSecond, which is per-second.
export const getCurrentEventsPerSecond = (periodMs: number): number => rate / (periodMs / 1000 / Math.LN2)

export const resetAdaptivePowState = (): void => {
rate = 0
lastEventAt = 0
}

export const getCurrentDifficulty = (config: AdaptivePowSettings): number => {
const eventsPerSecond = getCurrentEventsPerSecond(config.periodMs)

if (config.targetEventsPerSecond <= 0 || eventsPerSecond <= config.targetEventsPerSecond) {
return config.floorBits
}

const ratio = eventsPerSecond / config.targetEventsPerSecond
const scaled = config.floorBits + Math.ceil((ratio - 1) * (config.ceilingBits - config.floorBits))
Comment thread
Priyanshubhartistm marked this conversation as resolved.

return Math.max(config.floorBits, Math.min(config.ceilingBits, scaled))
}
19 changes: 19 additions & 0 deletions src/utils/settings-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,25 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => {
issues.push({ path: 'limits.rateLimiter.strategy', message: 'strategy must be ewma or sliding_window' })
}

const pow = settings.limits?.event?.pow
if (pow?.enabled) {
if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) {
issues.push({ path: 'limits.event.pow.floorBits', message: 'floorBits must be >= 0 and <= ceilingBits' })
}
if (!(pow.ceilingBits <= 256)) {
issues.push({ path: 'limits.event.pow.ceilingBits', message: 'ceilingBits must be <= 256' })
}
if (!(pow.periodMs > 0)) {
issues.push({ path: 'limits.event.pow.periodMs', message: 'periodMs must be greater than 0' })
}
if (!(pow.targetEventsPerSecond > 0)) {
issues.push({
path: 'limits.event.pow.targetEventsPerSecond',
message: 'targetEventsPerSecond must be greater than 0',
})
}
}

validateShape(loadDefaults(), settings, [], issues)

return issues
Expand Down
Loading
Loading