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/adaptive-pow-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"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
`minLeadingZeroBits` values. 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