diff --git a/.changeset/adaptive-pow-pipeline.md b/.changeset/adaptive-pow-pipeline.md new file mode 100644 index 00000000..5bc5229e --- /dev/null +++ b/.changeset/adaptive-pow-pipeline.md @@ -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. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index af32cbd0..a9bae07b 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | +| 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. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index b08e623c..befe7609 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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: [] diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 9bcbd154..6683fa9d 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -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 @@ -101,6 +114,7 @@ export interface EventLimits { rateLimits?: EventRateLimit[] whitelists?: EventWhitelists retention?: EventRetentionLimits + pow?: AdaptivePowSettings } export interface ClientSubscriptionLimits { diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index f3dc5fb9..2d2dff20 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -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' @@ -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, ...) + // 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') { @@ -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) { + 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}` + } } } diff --git a/src/handlers/request-handlers/root-request-handler.ts b/src/handlers/request-handlers/root-request-handler.ts index d965d66b..fb6dcc64 100644 --- a/src/handlers/request-handlers/root-request-handler.ts +++ b/src/handlers/request-handlers/root-request-handler.ts @@ -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 || @@ -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, diff --git a/src/utils/adaptive-pow.ts b/src/utils/adaptive-pow.ts new file mode 100644 index 00000000..5c526fab --- /dev/null +++ b/src/utils/adaptive-pow.ts @@ -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)) + + return Math.max(config.floorBits, Math.min(config.ceilingBits, scaled)) +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index d6b83e72..de0a9345 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -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 diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index fa3b646b..4e942511 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -17,6 +17,7 @@ import { EventKinds, EventExpirationTimeMetadataKey, EventTags } from '../../../ import { EventMessageHandler } from '../../../src/handlers/event-message-handler' import { IUserRepository } from '../../../src/@types/repositories' import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { getCurrentRate, recordEvent, resetAdaptivePowState } from '../../../src/utils/adaptive-pow' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' import * as nip05Utils from '../../../src/utils/nip05' @@ -74,6 +75,7 @@ describe('EventMessageHandler', () => { let isUserAdmitted: Sinon.SinonStub beforeEach(() => { + resetAdaptivePowState() canAcceptEventStub = sandbox.stub(EventMessageHandler.prototype, 'canAcceptEvent' as any) isEventValidStub = sandbox.stub(EventMessageHandler.prototype, 'isEventValid' as any) isUserAdmitted = sandbox.stub(EventMessageHandler.prototype, 'isUserAdmitted' as any) @@ -240,6 +242,74 @@ describe('EventMessageHandler', () => { return expect(handler.handleMessage(message)).to.eventually.be.fulfilled }) + + describe('adaptive pow load recording', () => { + const powSettings = () => + ({ + info: { relay_url: 'relay_url' }, + limits: { + event: { + pow: { enabled: true, floorBits: 8, ceilingBits: 24, targetEventsPerSecond: 10, periodMs: 60000 }, + }, + }, + }) as any + + it('does not record load for an event rejected before acceptance (canAcceptEvent)', async () => { + const powHandler = new EventMessageHandler( + webSocket as any, + strategyFactoryStub, + eventRepository, + userRepository, + powSettings, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + ) + canAcceptEventStub.returns('rejected: pow') + + await powHandler.handleMessage(message) + + expect(getCurrentRate()).to.equal(0) + }) + + it('does not record load for an event rejected by rate limiting', async () => { + const powHandler = new EventMessageHandler( + webSocket as any, + strategyFactoryStub, + eventRepository, + userRepository, + powSettings, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + ) + isRateLimitedStub.resolves(true) + + await powHandler.handleMessage(message) + + expect(getCurrentRate()).to.equal(0) + }) + + it('records load only once the event clears every check and is dispatched', async () => { + const powHandler = new EventMessageHandler( + webSocket as any, + strategyFactoryStub, + eventRepository, + userRepository, + powSettings, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + ) + isEventValidStub.returns(undefined) + canAcceptEventStub.returns(undefined) + + await powHandler.handleMessage(message) + + expect(getCurrentRate()).to.be.greaterThan(0) + expect(strategyExecuteStub).to.have.been.calledOnceWithExactly(event) + }) + }) }) describe('canAcceptEvent', () => { @@ -491,6 +561,112 @@ describe('EventMessageHandler', () => { }) }) + describe('pow (adaptive)', () => { + beforeEach(() => { + resetAdaptivePowState() + }) + + it('uses the floor difficulty while the observed rate is at or under target', () => { + eventLimits.pow = { + enabled: true, + floorBits: 8, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + } + event.id = '00' + 'f'.repeat(62) // 8 leading zero bits + event.pubkey = '00001' + 'f'.repeat(59) // well above the floor + + expect((handler as any).canAcceptEvent(event)).to.be.undefined + }) + + it('rejects with the floor difficulty when insufficient and under target', () => { + eventLimits.pow = { + enabled: true, + floorBits: 9, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + } + event.id = '00' + 'f'.repeat(62) // 8 leading zero bits + + expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<9') + }) + + it('checks pubkey proof of work against the adaptive difficulty too', () => { + eventLimits.pow = { + enabled: true, + floorBits: 9, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + } + event.id = '0001' + 'f'.repeat(60) // sufficient eventId pow + event.pubkey = '00' + 'f'.repeat(62) // 8 leading zero bits, insufficient + + expect((handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<9') + }) + + it('scales the required difficulty up as the sustained recorded rate exceeds target', () => { + eventLimits.pow = { + enabled: true, + floorBits: 8, + ceilingBits: 24, + targetEventsPerSecond: 2, + periodMs: 60000, + } + event.id = '00' + 'f'.repeat(62) // 8 leading zero bits, passes only the floor + event.pubkey = '00001' + 'f'.repeat(59) // well above floor and the scaled-up ceiling used here + + // canAcceptEvent only reads the current difficulty -- it no longer records + // load itself (that now happens in handleMessage, after full acceptance). + // Simulate a real sustained rate of 2.6 events/sec (1.3x the 2/s target, + // deliberately not 1.5x -- that ratio sits exactly on ceil()'s integer + // boundary, which the EWMA's convergence can overshoot by a hair) by + // driving recordEvent() with time-spaced calls until the EWMA converges. + const periodMs = 60000 + const intervalMs = 1000 / 2.6 + let now = 1000 + for (let i = 0; i < Math.ceil((periodMs * 20) / intervalMs); i++) { + recordEvent(periodMs, now) + now += intervalMs + } + + expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<13') // ratio=1.3 -> 8+ceil(0.3*16)=13 + }) + + it('does not record load itself -- repeated calls do not change the observed rate', () => { + eventLimits.pow = { + enabled: true, + floorBits: 8, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + } + event.id = '00' + 'f'.repeat(62) // 8 leading zero bits + event.pubkey = '00001' + 'f'.repeat(59) // well above the floor + + ;(handler as any).canAcceptEvent(event) + ;(handler as any).canAcceptEvent(event) + ;(handler as any).canAcceptEvent(event) + + expect(getCurrentRate()).to.equal(0) + }) + + it('ignores the static minLeadingZeroBits settings while adaptive pow is enabled', () => { + eventLimits.eventId.minLeadingZeroBits = 40 + eventLimits.pow = { + enabled: true, + floorBits: 0, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + } + + expect((handler as any).canAcceptEvent(event)).to.be.undefined + }) + }) + describe('blacklist', () => { it('returns undefined if blacklist is empty', () => { eventLimits.pubkey.blacklist = [] diff --git a/test/unit/handlers/request-handlers/root-request-handler.spec.ts b/test/unit/handlers/request-handlers/root-request-handler.spec.ts index fed61fa4..b77fb31f 100644 --- a/test/unit/handlers/request-handlers/root-request-handler.spec.ts +++ b/test/unit/handlers/request-handlers/root-request-handler.spec.ts @@ -307,6 +307,51 @@ describe('rootRequestHandler', () => { expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) }) + it('advertises the static minLeadingZeroBits as min_pow_difficulty when adaptive pow is disabled', () => { + createSettingsStub.returns({ + ...baseSettings, + limits: { + ...baseSettings.limits, + event: { ...baseSettings.limits.event, eventId: { minLeadingZeroBits: 12 } }, + }, + }) + rootRequestHandler(req, res, next) + expect(res.send.firstCall.args[0].limitation.min_pow_difficulty).to.equal(12) + }) + + it('advertises the adaptive pow floor as min_pow_difficulty when adaptive pow is enabled', () => { + createSettingsStub.returns({ + ...baseSettings, + limits: { + ...baseSettings.limits, + event: { + ...baseSettings.limits.event, + eventId: { minLeadingZeroBits: 12 }, + pow: { enabled: true, floorBits: 8, ceilingBits: 24, targetEventsPerSecond: 10, periodMs: 60000 }, + }, + }, + }) + rootRequestHandler(req, res, next) + // The live requirement can be higher than floorBits under load, but floorBits + // is the only number the relay can promise as a static minimum. + expect(res.send.firstCall.args[0].limitation.min_pow_difficulty).to.equal(8) + }) + + it('sets limitation.restricted_writes when adaptive pow is enabled', () => { + createSettingsStub.returns({ + ...baseSettings, + limits: { + ...baseSettings.limits, + event: { + ...baseSettings.limits.event, + pow: { enabled: true, floorBits: 8, ceilingBits: 24, targetEventsPerSecond: 10, periodMs: 60000 }, + }, + }, + }) + rootRequestHandler(req, res, next) + expect(res.send.firstCall.args[0].limitation.restricted_writes).to.equal(true) + }) + it('returns empty fees instead of crashing when the payments block is absent', () => { const { payments: _payments, ...settingsWithoutPayments } = baseSettings createSettingsStub.returns(settingsWithoutPayments) diff --git a/test/unit/utils/adaptive-pow.spec.ts b/test/unit/utils/adaptive-pow.spec.ts new file mode 100644 index 00000000..d84cdf14 --- /dev/null +++ b/test/unit/utils/adaptive-pow.spec.ts @@ -0,0 +1,144 @@ +import { expect } from 'chai' + +import { AdaptivePowSettings } from '../../../src/@types/settings' +import { + getCurrentDifficulty, + getCurrentEventsPerSecond, + getCurrentRate, + recordEvent, + resetAdaptivePowState, +} from '../../../src/utils/adaptive-pow' + +// Drives recordEvent() with events spaced 1000/eventsPerSecond ms apart for +// long enough (20 half-lives) to converge to the EWMA steady state for a +// real sustained rate -- same-instant bursts don't exercise the time +// normalization that getCurrentDifficulty applies to `rate`. +const simulateSustainedRate = (eventsPerSecond: number, periodMs: number, startAt = 1000): void => { + const intervalMs = 1000 / eventsPerSecond + const iterations = Math.ceil((periodMs * 20) / intervalMs) + let now = startAt + + for (let i = 0; i < iterations; i++) { + recordEvent(periodMs, now) + now += intervalMs + } +} + +describe('adaptive-pow', () => { + const config = (overrides: Partial = {}): AdaptivePowSettings => ({ + enabled: true, + floorBits: 8, + ceilingBits: 24, + targetEventsPerSecond: 10, + periodMs: 60000, + ...overrides, + }) + + beforeEach(() => { + resetAdaptivePowState() + }) + + describe('getCurrentRate', () => { + it('starts at zero', () => { + expect(getCurrentRate()).to.equal(0) + }) + + it('accumulates by step=1 per recorded event at the same instant', () => { + recordEvent(60000, 1000) + recordEvent(60000, 1000) + recordEvent(60000, 1000) + + expect(getCurrentRate()).to.equal(3) + }) + + it('decays toward zero as time passes between events', () => { + recordEvent(60000, 1000) + const rateBeforeDecay = getCurrentRate() + + recordEvent(60000, 1000 + 60000) // one full half-life later + const rateAfterDecay = getCurrentRate() + + expect(rateAfterDecay).to.be.lessThan(rateBeforeDecay + 1) + expect(rateAfterDecay).to.be.greaterThan(1) // decayed contribution + the new hit + }) + }) + + describe('getCurrentEventsPerSecond', () => { + it('is zero when no events have been recorded', () => { + expect(getCurrentEventsPerSecond(60000)).to.equal(0) + }) + + it('converts the raw EWMA count back to a real events-per-second rate', () => { + // A raw `rate` of 1 (single recorded event) is not "1 event/sec" -- it + // must be divided by periodMs/1000/ln(2) (~86.56 at the 60s default) + // to land in real events/sec units. + recordEvent(60000, 1000) + expect(getCurrentEventsPerSecond(60000)).to.be.closeTo(1 / (60000 / 1000 / Math.LN2), 1e-9) + }) + + it('reflects a real sustained rate after convergence', () => { + simulateSustainedRate(5, 60000) + expect(getCurrentEventsPerSecond(60000)).to.be.closeTo(5, 0.1) + }) + }) + + describe('getCurrentDifficulty', () => { + it('returns floorBits when no events have been recorded', () => { + expect(getCurrentDifficulty(config())).to.equal(8) + }) + + it('returns floorBits for a sustained rate safely under target', () => { + simulateSustainedRate(5, 60000) // target is 10/s + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 10 }))).to.equal(8) + }) + + it('scales up linearly between floor and ceiling once the sustained rate exceeds target', () => { + simulateSustainedRate(13, 60000) // 1.3x the 10/s target + // Deliberately not 1.5x: that ratio lands the scaled value exactly on ceil()'s + // integer boundary (0.5 * 16 = 8), where the EWMA's steady-state convergence + // overshoots by a hair and flips the result to the next integer. + + // ratio=1.3 -> 8 + ceil(0.3 * (24-8)) = 8 + 5 = 13 + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 10 }))).to.equal(13) + }) + + it('reaches ceilingBits at 2x the sustained target rate', () => { + simulateSustainedRate(20, 60000) // exactly 2x the 10/s target + + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 10, ceilingBits: 24 }))).to.equal(24) + }) + + it('clamps at ceilingBits no matter how far the sustained rate exceeds target', () => { + simulateSustainedRate(200, 60000) // far beyond 2x target + + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 10, ceilingBits: 24 }))).to.equal(24) + }) + + it('clamps at floorBits even if a misconfigured ceiling sits below the floor', () => { + simulateSustainedRate(200, 60000) + + // ceilingBits < floorBits would otherwise scale to a value below floorBits; + // validateSettings rejects this combination, but the pure function still + // defends against it directly. + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 10, floorBits: 20, ceilingBits: 10 }))).to.equal(20) + }) + + it('returns floorBits when targetEventsPerSecond is non-positive', () => { + simulateSustainedRate(50, 60000) + + expect(getCurrentDifficulty(config({ targetEventsPerSecond: 0 }))).to.equal(8) + }) + }) + + describe('resetAdaptivePowState', () => { + it('clears the accumulated rate back to zero', () => { + recordEvent(60000, 1000) + recordEvent(60000, 1000) + expect(getCurrentRate()).to.be.greaterThan(0) + + resetAdaptivePowState() + + expect(getCurrentRate()).to.equal(0) + }) + }) +}) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 975748aa..8e816415 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -97,6 +97,67 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'network')).to.equal(true) }) + describe('adaptive pow settings', () => { + const baseSettings = () => + ({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + limits: { + event: { + pow: { enabled: true, floorBits: 8, ceilingBits: 24, targetEventsPerSecond: 10, periodMs: 60000 }, + }, + }, + }) as any + + it('accepts a valid pow config', () => { + const issues = validateSettings(baseSettings()) + expect(issues.some((issue) => issue.path.startsWith('limits.event.pow'))).to.equal(false) + }) + + it('rejects floorBits greater than ceilingBits', () => { + const settings = baseSettings() + settings.limits.event.pow.floorBits = 20 + settings.limits.event.pow.ceilingBits = 10 + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.floorBits')).to.equal(true) + }) + + it('rejects a negative floorBits', () => { + const settings = baseSettings() + settings.limits.event.pow.floorBits = -1 + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.floorBits')).to.equal(true) + }) + + it('rejects a non-positive periodMs', () => { + const settings = baseSettings() + settings.limits.event.pow.periodMs = 0 + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.periodMs')).to.equal(true) + }) + + it('rejects a non-positive targetEventsPerSecond', () => { + const settings = baseSettings() + settings.limits.event.pow.targetEventsPerSecond = 0 + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.targetEventsPerSecond')).to.equal(true) + }) + + it('skips validation entirely when pow is disabled', () => { + const settings = baseSettings() + settings.limits.event.pow.enabled = false + settings.limits.event.pow.floorBits = -1 + settings.limits.event.pow.periodMs = -1 + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path.startsWith('limits.event.pow'))).to.equal(false) + }) + }) + it('formats setting category labels', () => { expect(toCategoryLabel('payments_processors')).to.equal('Payments Processors') expect(toCategoryLabel('rate_limiter')).to.equal('Rate Limiter')