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 @@ -159,11 +159,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 2x `targetEventsPerSecond` and beyond. |
Comment thread
Priyanshubhartistm marked this conversation as resolved.
| limits.event.pow.enabled | Enables load-aware PoW difficulty scaling, applied to both eventId and pubkey checks, in place of the static `minLeadingZeroBits` values. Defaults to false. |
| limits.event.pow.floorBits | Minimum adaptive PoW difficulty, used at or under `targetEventsPerSecond`. |
| limits.event.pow.periodMs | EWMA half-life (ms) used to smooth the observed event rate. |
| limits.event.pow.targetEventsPerSecond | Event-rate threshold 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. 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.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
9 changes: 9 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ limits:
whitelist: []
eventId:
minLeadingZeroBits: 0
# Adaptive PoW: scales the required difficulty (applied to both eventId
# and pubkey checks) with observed relay load instead of using a fixed
# minLeadingZeroBits value. 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; replaces the eventId/pubkey minLeadingZeroBits checks while enabled. Defaults to false. */
enabled: boolean
/** Minimum required difficulty, used at/under targetEventsPerSecond. */
floorBits: number
/** Maximum required difficulty, reached at 2x targetEventsPerSecond and beyond. */
ceilingBits: number
/** Event-rate threshold (same EWMA scale as limits.event.rateLimits) above which difficulty starts climbing. */
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
44 changes: 36 additions & 8 deletions 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,16 @@ export class EventMessageHandler implements IMessageHandler {
return
}

// Recorded here, not inside canAcceptEvent's PoW branch: only events that
// clear every admission check (PoW, blacklist, auth, NIP-05, dedup, ...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dedup is not actually cleared before this point -- the strategies only learn whether the write landed (count ? '' : 'duplicate:') inside strategy.execute, which runs after the recording. So replayed/duplicate events still count toward the load signal. Either drop dedup from the comment, or move the recording after strategy.execute.

// 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.
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,17 +209,31 @@ 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) {
if (limits.pow?.enabled) {
Comment thread
Priyanshubhartistm marked this conversation as resolved.
const requiredBits = getAdaptivePowDifficulty(limits.pow)

const pow = getEventProofOfWork(event.id)
if (pow < limits.eventId.minLeadingZeroBits) {
return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`
if (pow < requiredBits) {
return `pow: difficulty ${pow}<${requiredBits}`
}
}

if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) {
const pow = getPubkeyProofOfWork(event.pubkey)
if (pow < limits.pubkey.minLeadingZeroBits) {
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
const pubkeyPow = getPubkeyProofOfWork(event.pubkey)
if (pubkeyPow < requiredBits) {
return `pow: pubkey difficulty ${pubkeyPow}<${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}`
}
}

if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) {
const pow = getPubkeyProofOfWork(event.pubkey)
if (pow < limits.pubkey.minLeadingZeroBits) {
return `pow: pubkey difficulty ${pow}<${limits.pubkey.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 @@ -107,7 +108,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))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This single requiredBits is also applied to the pubkey check in canAcceptEvent, but getPubkeyProofOfWork cannot be mined per-event the way an event id can -- it requires generating fresh keypairs until the public key carries that prefix. With the shipped ceilingBits: 24 that is ~16.8M keypairs (~2,500/sec with the Node stdlib on this box), and an existing identity can never comply retroactively, whatever it pays in event-id mining. Under sustained accepted load, every author whose key lacks the prefix is simply locked out. Suggest keeping the static pubkey.minLeadingZeroBits check while adaptive PoW is enabled, or adding a separate pubkeyCeilingBits. Detail in the review body.


return Math.max(config.floorBits, Math.min(config.ceilingBits, scaled))
}
16 changes: 16 additions & 0 deletions src/utils/settings-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,22 @@ 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.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