From 9b37c39aa8e6bb8a6f48ddc8cb3e3d26521de1c7 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 15 Sep 2026 22:16:21 +0530 Subject: [PATCH 1/2] feat(pow): apply WoT-distance-based PoW reductions Signed-off-by: Priyanshubhartistm --- .changeset/wot-aware-pow-policy.md | 11 ++++++++++ CONFIGURATION.md | 2 ++ resources/default-settings.yaml | 9 ++++++++ src/@types/settings.ts | 20 ++++++++++++++++++ src/factories/message-handler-factory.ts | 2 ++ src/handlers/event-message-handler.ts | 16 ++++++++++++--- src/utils/settings-config.ts | 16 +++++++++++++++ src/utils/wot-pow-policy.ts | 26 ++++++++++++++++++++++++ 8 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 .changeset/wot-aware-pow-policy.md create mode 100644 src/utils/wot-pow-policy.ts diff --git a/.changeset/wot-aware-pow-policy.md b/.changeset/wot-aware-pow-policy.md new file mode 100644 index 00000000..dfede14d --- /dev/null +++ b/.changeset/wot-aware-pow-policy.md @@ -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. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 847e45a0..ff08cd7e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index bbb29dde..4ce8a8cc 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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: [] diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 7e91922f..f1f50e0c 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -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 @@ -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 { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 3bf1a3f5..1e078c95 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -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 => { @@ -53,6 +54,7 @@ export const messageHandlerFactory = nip05VerificationRepository, getCache(), rateLimiterFactory, + wotGraphServiceFactory(getCache(), eventRepository, createSettings), ) } case MessageType.REQ: diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 6bd46e84..86124909 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -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' @@ -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' @@ -58,6 +60,7 @@ export class EventMessageHandler implements IMessageHandler { private readonly nip05VerificationRepository: INip05VerificationRepository, private readonly cache: ICacheAdapter, private readonly rateLimiter: Factory, + private readonly wotGraphService: IWotGraphService, ) {} public async handleMessage(message: IncomingEventMessage): Promise { @@ -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)) @@ -167,7 +170,7 @@ export class EventMessageHandler implements IMessageHandler { return getPublicKey(relayPrivkey) } - protected canAcceptEvent(event: Event): string | undefined { + protected async canAcceptEvent(event: Event): Promise { if (this.getRelayPublicKey() === event.pubkey) { return } @@ -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) { diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index c2131e96..cc3eb6fd 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -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) diff --git a/src/utils/wot-pow-policy.ts b/src/utils/wot-pow-policy.ts new file mode 100644 index 00000000..6d2fb4fe --- /dev/null +++ b/src/utils/wot-pow-policy.ts @@ -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) +} From acc662239c5d24cb0e4e7c6918443bd600f2e7d2 Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Tue, 15 Sep 2026 22:18:17 +0530 Subject: [PATCH 2/2] test(pow): cover WoT-aware adaptive PoW policy Signed-off-by: Priyanshubhartistm --- .../handlers/event-message-handler.spec.ts | 311 +++++++++++------- test/unit/utils/settings-config.spec.ts | 31 ++ test/unit/utils/wot-pow-policy.spec.ts | 54 +++ 3 files changed, 286 insertions(+), 110 deletions(-) create mode 100644 test/unit/utils/wot-pow-policy.spec.ts diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 054e2b97..bcd88ced 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -104,6 +104,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -264,6 +265,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) canAcceptEventStub.returns('rejected: pow') @@ -282,6 +284,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) isRateLimitedStub.resolves(true) @@ -300,6 +303,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) isEventValidStub.returns(undefined) canAcceptEventStub.returns(undefined) @@ -359,6 +363,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -367,43 +372,43 @@ describe('EventMessageHandler', () => { }) describe('createdAt', () => { - it('returns undefined if event pubkey equals relay public key', () => { + it('returns undefined if event pubkey equals relay public key', async () => { sandbox.stub(EventMessageHandler.prototype, 'getRelayPublicKey' as any).returns(event.pubkey) eventLimits.createdAt.maxPositiveDelta = 1 event.created_at += 999 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) describe('maxPositiveDelta', () => { - it('returns undefined if maxPositiveDelta is zero', () => { + it('returns undefined if maxPositiveDelta is zero', async () => { eventLimits.createdAt.maxPositiveDelta = 0 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if createdDate is too far in the future', () => { + it('returns reason if createdDate is too far in the future', async () => { eventLimits.createdAt.maxPositiveDelta = 100 event.created_at += 101 expect( - (handler as any).canAcceptEvent(event) + await (handler as any).canAcceptEvent(event) ).to.equal('rejected: created_at is more than 100 seconds in the future') }) }) describe('maxNegativeDelta', () => { - it('returns undefined if maxNegativeDelta is zero', () => { + it('returns undefined if maxNegativeDelta is zero', async () => { eventLimits.createdAt.maxNegativeDelta = 0 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if createdDate is too far in the past', () => { + it('returns reason if createdDate is too far in the past', async () => { eventLimits.createdAt.maxNegativeDelta = 100 event.created_at -= 101 - expect((handler as any).canAcceptEvent(event)).to.equal( + expect(await (handler as any).canAcceptEvent(event)).to.equal( 'rejected: created_at is more than 100 seconds in the past', ) }) @@ -412,110 +417,110 @@ describe('EventMessageHandler', () => { describe('content', () => { describe('maxLength', () => { - it('returns undefined if maxLength is disabled', () => { + it('returns undefined if maxLength is disabled', async () => { eventLimits.content = [{ maxLength: 0 }] - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefned if content is not too long', () => { + it('returns undefned if content is not too long', async () => { eventLimits.content = [{ maxLength: 1 }] event.content = 'x'.repeat(1) - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind does not match', () => { + it('returns undefined if kind does not match', async () => { eventLimits.content = [{ kinds: [EventKinds.SET_METADATA], maxLength: 1 }] event.content = 'x' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind matches but content is short', () => { + it('returns undefined if kind matches but content is short', async () => { eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }] event.content = 'x' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if kind matches but content is too long', () => { + it('returns reason if kind matches but content is too long', async () => { eventLimits.content = [{ kinds: [EventKinds.TEXT_NOTE], maxLength: 1 }] event.content = 'xx' - expect((handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') + expect(await (handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') }) - it('returns reason if content is too long', () => { + it('returns reason if content is too long', async () => { eventLimits.content = [{ maxLength: 1 }] event.content = 'x'.repeat(2) - expect((handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') + expect(await (handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') }) }) describe('maxLength (deprecated)', () => { - it('returns undefined if maxLength is zero', () => { + it('returns undefined if maxLength is zero', async () => { eventLimits.content = { maxLength: 0 } - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if content is short', () => { + it('returns undefined if content is short', async () => { eventLimits.content = { maxLength: 100 } event.content = 'x'.repeat(100) - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if content is too long', () => { + it('returns reason if content is too long', async () => { eventLimits.content = { maxLength: 1 } event.content = 'xx' - expect((handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') + expect(await (handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') }) - it('returns undefined if kind matches and content is short', () => { + it('returns undefined if kind matches and content is short', async () => { eventLimits.content = { kinds: [EventKinds.TEXT_NOTE], maxLength: 1 } event.content = 'x' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind does not match and content is too long', () => { + it('returns undefined if kind does not match and content is too long', async () => { eventLimits.content = { kinds: [EventKinds.SET_METADATA], maxLength: 1 } event.content = 'xx' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if content is too long', () => { + it('returns reason if content is too long', async () => { eventLimits.content = { maxLength: 1 } event.content = 'xx' - expect((handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') + expect(await (handler as any).canAcceptEvent(event)).to.equal('rejected: content is longer than 1 bytes') }) - it('returns undefined if content is not set', () => { + it('returns undefined if content is not set', async () => { eventLimits.content = undefined event.content = 'xx' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) }) describe('maxNegativeDelta', () => { - it('returns undefined if maxNegativeDelta is zero', () => { + it('returns undefined if maxNegativeDelta is zero', async () => { eventLimits.createdAt.maxNegativeDelta = 0 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if createdDate is too far in the past', () => { + it('returns reason if createdDate is too far in the past', async () => { eventLimits.createdAt.maxNegativeDelta = 100 event.created_at -= 101 - expect((handler as any).canAcceptEvent(event)).to.equal( + expect(await (handler as any).canAcceptEvent(event)).to.equal( 'rejected: created_at is more than 100 seconds in the past', ) }) @@ -524,40 +529,40 @@ describe('EventMessageHandler', () => { describe('eventId', () => { describe('minLeadingZeroBits', () => { - it('returns undefined if minLeadingZeroBits is zero', () => { - expect((handler as any).canAcceptEvent(event)).to.be.undefined + it('returns undefined if minLeadingZeroBits is zero', async () => { + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if eventId has sufficient proof of work ', () => { + it('returns undefined if eventId has sufficient proof of work ', async () => { eventLimits.eventId.minLeadingZeroBits = 15 event.id = '0001' + 'f'.repeat(60) - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if eventId has insufficient proof of work ', () => { + it('returns reason if eventId has insufficient proof of work ', async () => { eventLimits.eventId.minLeadingZeroBits = 16 event.id = '00' + 'f'.repeat(62) - expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<16') + expect(await (handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<16') }) }) }) describe('pubkey', () => { describe('minLeadingZeroBits', () => { - it('returns undefined if minLeadingZeroBits is zero', () => { - expect((handler as any).canAcceptEvent(event)).to.be.undefined + it('returns undefined if minLeadingZeroBits is zero', async () => { + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if pubkey has sufficient proof of work ', () => { + it('returns undefined if pubkey has sufficient proof of work ', async () => { eventLimits.pubkey.minLeadingZeroBits = 17 event.pubkey = '00007' + 'f'.repeat(59) - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if pubkey has insufficient proof of work ', () => { + it('returns reason if pubkey has insufficient proof of work ', async () => { eventLimits.pubkey.minLeadingZeroBits = 16 event.pubkey = '0'.repeat(2) + 'f'.repeat(62) - expect((handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<16') + expect(await (handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<16') }) }) @@ -566,7 +571,7 @@ describe('EventMessageHandler', () => { resetAdaptivePowState() }) - it('uses the floor difficulty while the observed rate is at or under target', () => { + it('uses the floor difficulty while the observed rate is at or under target', async () => { eventLimits.pow = { enabled: true, floorBits: 8, @@ -577,10 +582,10 @@ describe('EventMessageHandler', () => { event.id = '00' + 'f'.repeat(62) // 8 leading zero bits event.pubkey = '00001' + 'f'.repeat(59) // irrelevant here: pubkey.minLeadingZeroBits is unset - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('rejects with the floor difficulty when insufficient and under target', () => { + it('rejects with the floor difficulty when insufficient and under target', async () => { eventLimits.pow = { enabled: true, floorBits: 9, @@ -590,10 +595,10 @@ describe('EventMessageHandler', () => { } event.id = '00' + 'f'.repeat(62) // 8 leading zero bits - expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<9') + expect(await (handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<9') }) - it('does not apply the adaptive difficulty to the pubkey check', () => { + it('does not apply the adaptive difficulty to the pubkey check', async () => { eventLimits.pow = { enabled: true, floorBits: 9, @@ -606,10 +611,10 @@ describe('EventMessageHandler', () => { // pubkey.minLeadingZeroBits is unset (0/disabled), so this must pass: // adaptive PoW never gates the pubkey axis, only eventId. - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('still enforces the static pubkey.minLeadingZeroBits setting while adaptive pow is enabled', () => { + it('still enforces the static pubkey.minLeadingZeroBits setting while adaptive pow is enabled', async () => { eventLimits.pubkey.minLeadingZeroBits = 16 eventLimits.pow = { enabled: true, @@ -621,10 +626,10 @@ describe('EventMessageHandler', () => { event.id = '0001' + 'f'.repeat(60) // sufficient eventId pow (floor is 0 anyway) event.pubkey = '00' + 'f'.repeat(62) // 8 leading zero bits, insufficient against the static 16 - expect((handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<16') + expect(await (handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<16') }) - it('scales the required difficulty up as the sustained recorded rate exceeds target', () => { + it('scales the required difficulty up as the sustained recorded rate exceeds target', async () => { eventLimits.pow = { enabled: true, floorBits: 8, @@ -649,10 +654,10 @@ describe('EventMessageHandler', () => { now += intervalMs } - expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<13') // ratio=1.3 -> 8+ceil(0.3*16)=13 + expect(await (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', () => { + it('does not record load itself -- repeated calls do not change the observed rate', async () => { eventLimits.pow = { enabled: true, floorBits: 8, @@ -663,14 +668,14 @@ describe('EventMessageHandler', () => { event.id = '00' + 'f'.repeat(62) // 8 leading zero bits event.pubkey = '00001' + 'f'.repeat(59) // irrelevant here: pubkey.minLeadingZeroBits is unset - ;(handler as any).canAcceptEvent(event) - ;(handler as any).canAcceptEvent(event) - ;(handler as any).canAcceptEvent(event) + await (handler as any).canAcceptEvent(event) + await (handler as any).canAcceptEvent(event) + await (handler as any).canAcceptEvent(event) expect(getCurrentRate()).to.equal(0) }) - it('ignores the static eventId.minLeadingZeroBits setting while adaptive pow is enabled', () => { + it('ignores the static eventId.minLeadingZeroBits setting while adaptive pow is enabled', async () => { eventLimits.eventId.minLeadingZeroBits = 40 eventLimits.pow = { enabled: true, @@ -680,141 +685,214 @@ describe('EventMessageHandler', () => { periodMs: 60000, } - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined + }) + + describe('wotThresholds', () => { + let getDistanceStub: Sinon.SinonStub + let wotHandler: EventMessageHandler + + beforeEach(() => { + getDistanceStub = Sinon.stub() + wotHandler = new EventMessageHandler( + {} as any, + () => null, + {} as any, + userRepository, + () => settings, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + { getDistance: getDistanceStub } as any, + ) + + eventLimits.pow = { + enabled: true, + floorBits: 16, + ceilingBits: 24, + targetEventsPerSecond: 100, + periodMs: 60000, + wotThresholds: [ + { maxDistance: 1, difficultyFactor: 0 }, + { maxDistance: 2, difficultyFactor: 0.5 }, + ], + } + event.id = 'f'.repeat(64) // 0 leading zero bits -- fails any non-zero requirement + }) + + it('does not consult the WoT graph when no thresholds are configured', async () => { + eventLimits.pow!.wotThresholds = undefined + + await (wotHandler as any).canAcceptEvent(event) + + expect(getDistanceStub).not.to.have.been.called + }) + + it('bypasses eventId PoW entirely for a direct follow (distance 1)', async () => { + getDistanceStub.resolves(1) + + expect(await (wotHandler as any).canAcceptEvent(event)).to.be.undefined + }) + + it('applies a partial reduction at distance 2 (half of the floor of 16 is 8)', async () => { + getDistanceStub.resolves(2) + event.id = '00' + 'f'.repeat(62) // 8 leading zero bits -- meets the reduced requirement exactly + + expect(await (wotHandler as any).canAcceptEvent(event)).to.be.undefined + }) + + it('rejects at distance 2 when the event does not meet even the reduced requirement', async () => { + getDistanceStub.resolves(2) + event.id = '0' + 'f'.repeat(63) // 4 leading zero bits -- below the reduced requirement of 8 + + expect(await (wotHandler as any).canAcceptEvent(event)).to.equal('pow: difficulty 4<8') + }) + + it('requires the full computed difficulty for a pubkey outside the trust graph', async () => { + getDistanceStub.resolves(undefined) + + expect(await (wotHandler as any).canAcceptEvent(event)).to.equal('pow: difficulty 0<16') + }) + + it('requires the full computed difficulty beyond every threshold', async () => { + getDistanceStub.resolves(5) + + expect(await (wotHandler as any).canAcceptEvent(event)).to.equal('pow: difficulty 0<16') + }) }) }) describe('blacklist', () => { - it('returns undefined if blacklist is empty', () => { + it('returns undefined if blacklist is empty', async () => { eventLimits.pubkey.blacklist = [] - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if pubkey is not blacklisted', () => { + it('returns undefined if pubkey is not blacklisted', async () => { eventLimits.pubkey.blacklist = ['aabbcc'] event.pubkey = 'fffff' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if pubkey is not an exact match in the blacklist', () => { + it('returns undefined if pubkey is not an exact match in the blacklist', async () => { eventLimits.pubkey.blacklist = ['aa55'] event.pubkey = 'aabbcc' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if pubkey is blacklisted', () => { + it('returns reason if pubkey is blacklisted', async () => { eventLimits.pubkey.blacklist = ['aabbcc'] event.pubkey = 'aabbcc' - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') }) - it('returns undefined if pubkey extends a blacklist entry but is not an exact match', () => { + it('returns undefined if pubkey extends a blacklist entry but is not an exact match', async () => { eventLimits.pubkey.blacklist = ['aa55'] event.pubkey = 'aa55ccddeeff' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) }) describe('whitelist', () => { - it('returns undefined if whitelist is empty', () => { + it('returns undefined if whitelist is empty', async () => { eventLimits.pubkey.whitelist = [] - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if pubkey is whitelisted', () => { + it('returns undefined if pubkey is whitelisted', async () => { eventLimits.pubkey.whitelist = ['aabbcc'] event.pubkey = 'aabbcc' - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if pubkey is not an exact match in the whitelist', () => { + it('returns reason if pubkey is not an exact match in the whitelist', async () => { eventLimits.pubkey.whitelist = ['aa55'] event.pubkey = 'aa55ccddeeff' - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') }) - it('returns reason if pubkey is not whitelisted', () => { + it('returns reason if pubkey is not whitelisted', async () => { eventLimits.pubkey.whitelist = ['ffffff'] event.pubkey = 'aabbcc' - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') }) - it('returns reason if pubkey is not whitelisted by exact match', () => { + it('returns reason if pubkey is not whitelisted by exact match', async () => { eventLimits.pubkey.whitelist = ['aa55'] event.pubkey = 'aabbccddeeff' - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: pubkey not allowed') }) }) }) describe('kind', () => { describe('blacklist', () => { - it('returns undefined if blacklist is empty', () => { + it('returns undefined if blacklist is empty', async () => { eventLimits.kind.blacklist = [] - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind is not blacklisted', () => { + it('returns undefined if kind is not blacklisted', async () => { eventLimits.kind.blacklist = [5] event.kind = 4 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind is not blacklisted in range', () => { + it('returns undefined if kind is not blacklisted in range', async () => { eventLimits.kind.blacklist = [[1, 5]] event.kind = EventKinds.REACTION - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if kind is blacklisted in range', () => { + it('returns reason if kind is blacklisted in range', async () => { eventLimits.kind.blacklist = [[1, 5]] event.kind = 4 - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 4 not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 4 not allowed') }) }) describe('whitelist', () => { - it('returns undefined if whitelist is empty', () => { + it('returns undefined if whitelist is empty', async () => { eventLimits.kind.whitelist = [] - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind is whitelisted', () => { + it('returns undefined if kind is whitelisted', async () => { eventLimits.kind.whitelist = [5] event.kind = 5 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns undefined if kind is whitelisted in range', () => { + it('returns undefined if kind is whitelisted in range', async () => { eventLimits.kind.whitelist = [[1, 5]] event.kind = 3 - expect((handler as any).canAcceptEvent(event)).to.be.undefined + expect(await (handler as any).canAcceptEvent(event)).to.be.undefined }) - it('returns reason if kind is blacklisted and whitelisted in range', () => { + it('returns reason if kind is blacklisted and whitelisted in range', async () => { eventLimits.kind.blacklist = [3] eventLimits.kind.whitelist = [[1, 5]] event.kind = 3 - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 3 not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 3 not allowed') }) - it('returns reason if kind is blacklisted and whitelisted', () => { + it('returns reason if kind is blacklisted and whitelisted', async () => { eventLimits.kind.blacklist = [3] eventLimits.kind.whitelist = [3] event.kind = 3 - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 3 not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 3 not allowed') }) - it('returns reason if kind is not whitelisted', () => { + it('returns reason if kind is not whitelisted', async () => { eventLimits.kind.whitelist = [5] event.kind = 4 - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 4 not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 4 not allowed') }) - it('returns reason if kind is not whitelisted in range', () => { + it('returns reason if kind is not whitelisted in range', async () => { eventLimits.kind.whitelist = [[1, 5]] event.kind = EventKinds.REACTION - expect((handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 7 not allowed') + expect(await (handler as any).canAcceptEvent(event)).to.equal('blocked: event kind 7 not allowed') }) }) }) @@ -944,6 +1022,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -997,6 +1076,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: rateLimiterHitStub }), + {} as any, ) }) @@ -1322,6 +1402,7 @@ describe('EventMessageHandler', () => { {} as any, cacheStub, () => ({ hit: async () => false }), + {} as any, ) }) @@ -1595,6 +1676,7 @@ describe('EventMessageHandler', () => { nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -1750,6 +1832,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -1800,6 +1883,7 @@ describe('EventMessageHandler', () => { nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -2043,6 +2127,7 @@ describe('EventMessageHandler', () => { nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -2219,6 +2304,7 @@ describe('EventMessageHandler', () => { nip05VerificationRepository, { hasKey: async () => false, setKey: async () => true, getKey: async () => null } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -2417,6 +2503,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) expect((handler as any).isAuthenticationRequired(event)).to.be.undefined @@ -2432,6 +2519,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) expect((handler as any).isAuthenticationRequired(event)).to.equal( @@ -2449,6 +2537,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) expect((handler as any).isAuthenticationRequired(event)).to.be.undefined @@ -2471,6 +2560,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) }) @@ -2503,6 +2593,7 @@ describe('EventMessageHandler', () => { {} as any, { hasKey: async () => false, setKey: async () => true } as any, () => ({ hit: async () => false }), + {} as any, ) expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined }) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index c6eb644e..9f84b5ea 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -173,6 +173,37 @@ describe('settings-config', () => { const issues = validateSettings(settings) expect(issues.some((issue) => issue.path.startsWith('limits.event.pow'))).to.equal(false) }) + + it('accepts valid wotThresholds', () => { + const settings = baseSettings() + settings.limits.event.pow.wotThresholds = [ + { maxDistance: 1, difficultyFactor: 0 }, + { maxDistance: 2, difficultyFactor: 0.5 }, + ] + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path.startsWith('limits.event.pow.wotThresholds'))).to.equal(false) + }) + + it('rejects a negative wotThresholds maxDistance', () => { + const settings = baseSettings() + settings.limits.event.pow.wotThresholds = [{ maxDistance: -1, difficultyFactor: 0.5 }] + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.wotThresholds[0].maxDistance')).to.equal(true) + }) + + it('rejects a wotThresholds difficultyFactor outside [0, 1]', () => { + const settings = baseSettings() + settings.limits.event.pow.wotThresholds = [ + { maxDistance: 1, difficultyFactor: -0.1 }, + { maxDistance: 2, difficultyFactor: 1.1 }, + ] + + const issues = validateSettings(settings) + expect(issues.some((issue) => issue.path === 'limits.event.pow.wotThresholds[0].difficultyFactor')).to.equal(true) + expect(issues.some((issue) => issue.path === 'limits.event.pow.wotThresholds[1].difficultyFactor')).to.equal(true) + }) }) it('formats setting category labels', () => { diff --git a/test/unit/utils/wot-pow-policy.spec.ts b/test/unit/utils/wot-pow-policy.spec.ts new file mode 100644 index 00000000..d5ae656c --- /dev/null +++ b/test/unit/utils/wot-pow-policy.spec.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai' + +import { applyWotPowPolicy } from '../../../src/utils/wot-pow-policy' +import { WotPowThreshold } from '../../../src/@types/settings' + +describe('applyWotPowPolicy', () => { + const thresholds: WotPowThreshold[] = [ + { maxDistance: 1, difficultyFactor: 0 }, + { maxDistance: 2, difficultyFactor: 0.5 }, + ] + + it('returns the computed difficulty unchanged when no thresholds are configured', () => { + expect(applyWotPowPolicy(16, 1, undefined)).to.equal(16) + expect(applyWotPowPolicy(16, 1, [])).to.equal(16) + }) + + it('returns the computed difficulty unchanged when distance is undefined (outside the trust graph)', () => { + expect(applyWotPowPolicy(16, undefined, thresholds)).to.equal(16) + }) + + it('bypasses PoW entirely for a direct follow (distance 1, difficultyFactor 0)', () => { + expect(applyWotPowPolicy(16, 1, thresholds)).to.equal(0) + }) + + it('applies a partial reduction at distance 2', () => { + expect(applyWotPowPolicy(16, 2, thresholds)).to.equal(8) + }) + + it('rounds a partial reduction up (defender-favorable)', () => { + expect(applyWotPowPolicy(15, 2, thresholds)).to.equal(8) // ceil(15 * 0.5) = 8 + }) + + it('returns the full computed difficulty for a distance beyond every threshold', () => { + expect(applyWotPowPolicy(16, 3, thresholds)).to.equal(16) + }) + + it('treats distance 0 (the seed pubkey itself) as eligible for the smallest threshold', () => { + expect(applyWotPowPolicy(16, 0, thresholds)).to.equal(0) + }) + + it('picks the closest (smallest maxDistance) eligible threshold', () => { + // distance 1 is eligible for both the maxDistance:1 and maxDistance:2 thresholds -- + // the tighter one (maxDistance:1, factor 0) should win, not the looser one. + const reordered: WotPowThreshold[] = [ + { maxDistance: 2, difficultyFactor: 0.5 }, + { maxDistance: 1, difficultyFactor: 0 }, + ] + expect(applyWotPowPolicy(16, 1, reordered)).to.equal(0) + }) + + it('supports a single bypass-everyone-in-the-graph threshold', () => { + expect(applyWotPowPolicy(24, 10, [{ maxDistance: 10, difficultyFactor: 0 }])).to.equal(0) + }) +})