Skip to content

Commit 112ab0e

Browse files
fix(pow): keep adaptive difficulty scoped to eventId, not pubkey
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent e454e26 commit 112ab0e

7 files changed

Lines changed: 77 additions & 39 deletions

File tree

‎CONFIGURATION.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,13 @@ The settings below are listed in alphabetical order by name. Please keep this ta
162162
| 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). |
163163
| limits.event.kind.blacklist | List of event kinds to always reject. Leave empty to allow any. |
164164
| limits.event.kind.whitelist | List of event kinds to always allow. Leave empty to allow any. |
165-
| limits.event.pow.ceilingBits | Maximum adaptive PoW difficulty, reached at 2x `targetEventsPerSecond` and beyond. |
166-
| 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. |
167-
| limits.event.pow.floorBits | Minimum adaptive PoW difficulty, used at or under `targetEventsPerSecond`. |
165+
| limits.event.pow.ceilingBits | Maximum adaptive PoW difficulty, reached at approximately 2x `targetEventsPerSecond` and beyond. |
166+
| 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. |
167+
| 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. |
168168
| limits.event.pow.periodMs | EWMA half-life (ms) used to smooth the observed event rate. |
169-
| limits.event.pow.targetEventsPerSecond | Event-rate threshold above which the adaptive difficulty starts climbing toward `ceilingBits`. |
169+
| limits.event.pow.targetEventsPerSecond | Event-rate threshold (in real events/sec) above which the adaptive difficulty starts climbing toward `ceilingBits`. |
170170
| 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. |
171-
| 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). |
171+
| 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`). |
172172
| 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. |
173173
| limits.event.rateLimits[].kinds | List of event kinds rate limited. Use `[min, max]` for ranges. Optional. |
174174
| 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. |

‎resources/default-settings.yaml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,10 @@ limits:
218218
whitelist: []
219219
eventId:
220220
minLeadingZeroBits: 0
221-
# Adaptive PoW: scales the required difficulty (applied to both eventId
222-
# and pubkey checks) with observed relay load instead of using a fixed
223-
# minLeadingZeroBits value. Disabled by default.
221+
# Adaptive PoW: scales the required difficulty on the eventId check with
222+
# observed relay load instead of using a fixed minLeadingZeroBits value.
223+
# Does not affect the pubkey check above -- that stays static regardless.
224+
# Disabled by default.
224225
pow:
225226
enabled: false
226227
floorBits: 0

‎src/@types/settings.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,13 @@ export interface EventRetentionLimits {
9393
}
9494

9595
export interface AdaptivePowSettings {
96-
/** Enables load-aware difficulty scaling; replaces the eventId/pubkey minLeadingZeroBits checks while enabled. Defaults to false. */
96+
/** 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. */
9797
enabled: boolean
98-
/** Minimum required difficulty, used at/under targetEventsPerSecond. */
98+
/** Minimum required difficulty, used at or below approximately targetEventsPerSecond. */
9999
floorBits: number
100-
/** Maximum required difficulty, reached at 2x targetEventsPerSecond and beyond. */
100+
/** Maximum required difficulty, reached at approximately 2x targetEventsPerSecond and beyond. */
101101
ceilingBits: number
102-
/** Event-rate threshold (same EWMA scale as limits.event.rateLimits) above which difficulty starts climbing. */
102+
/** Event-rate threshold, in real events/sec, above which difficulty starts climbing toward ceilingBits. */
103103
targetEventsPerSecond: number
104104
/** EWMA half-life in ms used to smooth the observed event rate. */
105105
periodMs: number

‎src/handlers/event-message-handler.ts‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ export class EventMessageHandler implements IMessageHandler {
132132
}
133133

134134
// Recorded here, not inside canAcceptEvent's PoW branch: only events that
135-
// clear every admission check (PoW, blacklist, auth, NIP-05, dedup, ...)
136-
// should count toward the load signal. Recording earlier would let cheap,
137-
// easily-rejected spam (e.g. from rotating pubkeys) push the difficulty to
138-
// ceiling for everyone without the attacker ever doing any real work.
135+
// clear every admission check up to this point (PoW, blacklist, auth,
136+
// NIP-05, ...) should count toward the load signal. Recording earlier
137+
// would let cheap, easily-rejected spam (e.g. from rotating pubkeys) push
138+
// the difficulty to ceiling for everyone without the attacker ever doing
139+
// any real work. Note: a duplicate/no-op write still counts here, since
140+
// dedup is decided later inside the event strategy's own execute().
139141
const powSettings = this.settings().limits?.event?.pow
140142
if (powSettings?.enabled) {
141143
recordAdaptivePowEvent(powSettings.periodMs)
@@ -209,31 +211,29 @@ export class EventMessageHandler implements IMessageHandler {
209211
return `rejected: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`
210212
}
211213

214+
// Adaptive PoW applies to the eventId check only: a pubkey requirement is a
215+
// one-time, offline identity cost, not a per-event load signal, so it can't
216+
// respond to relay load the way an event id's mined-per-submission pow can.
217+
// The static pubkey.minLeadingZeroBits knob is left untouched regardless of
218+
// pow.enabled -- per maintainer direction on PR #756.
212219
if (limits.pow?.enabled) {
213220
const requiredBits = getAdaptivePowDifficulty(limits.pow)
214221

215222
const pow = getEventProofOfWork(event.id)
216223
if (pow < requiredBits) {
217224
return `pow: difficulty ${pow}<${requiredBits}`
218225
}
219-
220-
const pubkeyPow = getPubkeyProofOfWork(event.pubkey)
221-
if (pubkeyPow < requiredBits) {
222-
return `pow: pubkey difficulty ${pubkeyPow}<${requiredBits}`
223-
}
224-
} else {
225-
if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) {
226-
const pow = getEventProofOfWork(event.id)
227-
if (pow < limits.eventId.minLeadingZeroBits) {
228-
return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`
229-
}
226+
} else if (typeof limits.eventId?.minLeadingZeroBits !== 'undefined' && limits.eventId.minLeadingZeroBits > 0) {
227+
const pow = getEventProofOfWork(event.id)
228+
if (pow < limits.eventId.minLeadingZeroBits) {
229+
return `pow: difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`
230230
}
231+
}
231232

232-
if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) {
233-
const pow = getPubkeyProofOfWork(event.pubkey)
234-
if (pow < limits.pubkey.minLeadingZeroBits) {
235-
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
236-
}
233+
if (typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' && limits.pubkey.minLeadingZeroBits > 0) {
234+
const pow = getPubkeyProofOfWork(event.pubkey)
235+
if (pow < limits.pubkey.minLeadingZeroBits) {
236+
return `pow: pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`
237237
}
238238
}
239239

‎src/utils/settings-config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,9 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => {
598598
if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) {
599599
issues.push({ path: 'limits.event.pow.floorBits', message: 'floorBits must be >= 0 and <= ceilingBits' })
600600
}
601+
if (!(pow.ceilingBits <= 256)) {
602+
issues.push({ path: 'limits.event.pow.ceilingBits', message: 'ceilingBits must be <= 256' })
603+
}
601604
if (!(pow.periodMs > 0)) {
602605
issues.push({ path: 'limits.event.pow.periodMs', message: 'periodMs must be greater than 0' })
603606
}

‎test/unit/handlers/event-message-handler.spec.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ describe('EventMessageHandler', () => {
575575
periodMs: 60000,
576576
}
577577
event.id = '00' + 'f'.repeat(62) // 8 leading zero bits
578-
event.pubkey = '00001' + 'f'.repeat(59) // well above the floor
578+
event.pubkey = '00001' + 'f'.repeat(59) // irrelevant here: pubkey.minLeadingZeroBits is unset
579579

580580
expect((handler as any).canAcceptEvent(event)).to.be.undefined
581581
})
@@ -593,7 +593,7 @@ describe('EventMessageHandler', () => {
593593
expect((handler as any).canAcceptEvent(event)).to.equal('pow: difficulty 8<9')
594594
})
595595

596-
it('checks pubkey proof of work against the adaptive difficulty too', () => {
596+
it('does not apply the adaptive difficulty to the pubkey check', () => {
597597
eventLimits.pow = {
598598
enabled: true,
599599
floorBits: 9,
@@ -602,9 +602,26 @@ describe('EventMessageHandler', () => {
602602
periodMs: 60000,
603603
}
604604
event.id = '0001' + 'f'.repeat(60) // sufficient eventId pow
605-
event.pubkey = '00' + 'f'.repeat(62) // 8 leading zero bits, insufficient
605+
event.pubkey = '00' + 'f'.repeat(62) // 8 leading zero bits -- below the adaptive floor of 9
606606

607-
expect((handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<9')
607+
// pubkey.minLeadingZeroBits is unset (0/disabled), so this must pass:
608+
// adaptive PoW never gates the pubkey axis, only eventId.
609+
expect((handler as any).canAcceptEvent(event)).to.be.undefined
610+
})
611+
612+
it('still enforces the static pubkey.minLeadingZeroBits setting while adaptive pow is enabled', () => {
613+
eventLimits.pubkey.minLeadingZeroBits = 16
614+
eventLimits.pow = {
615+
enabled: true,
616+
floorBits: 0,
617+
ceilingBits: 24,
618+
targetEventsPerSecond: 100,
619+
periodMs: 60000,
620+
}
621+
event.id = '0001' + 'f'.repeat(60) // sufficient eventId pow (floor is 0 anyway)
622+
event.pubkey = '00' + 'f'.repeat(62) // 8 leading zero bits, insufficient against the static 16
623+
624+
expect((handler as any).canAcceptEvent(event)).to.equal('pow: pubkey difficulty 8<16')
608625
})
609626

610627
it('scales the required difficulty up as the sustained recorded rate exceeds target', () => {
@@ -616,7 +633,7 @@ describe('EventMessageHandler', () => {
616633
periodMs: 60000,
617634
}
618635
event.id = '00' + 'f'.repeat(62) // 8 leading zero bits, passes only the floor
619-
event.pubkey = '00001' + 'f'.repeat(59) // well above floor and the scaled-up ceiling used here
636+
event.pubkey = '00001' + 'f'.repeat(59) // irrelevant here: pubkey.minLeadingZeroBits is unset
620637

621638
// canAcceptEvent only reads the current difficulty -- it no longer records
622639
// load itself (that now happens in handleMessage, after full acceptance).
@@ -644,7 +661,7 @@ describe('EventMessageHandler', () => {
644661
periodMs: 60000,
645662
}
646663
event.id = '00' + 'f'.repeat(62) // 8 leading zero bits
647-
event.pubkey = '00001' + 'f'.repeat(59) // well above the floor
664+
event.pubkey = '00001' + 'f'.repeat(59) // irrelevant here: pubkey.minLeadingZeroBits is unset
648665

649666
;(handler as any).canAcceptEvent(event)
650667
;(handler as any).canAcceptEvent(event)
@@ -653,7 +670,7 @@ describe('EventMessageHandler', () => {
653670
expect(getCurrentRate()).to.equal(0)
654671
})
655672

656-
it('ignores the static minLeadingZeroBits settings while adaptive pow is enabled', () => {
673+
it('ignores the static eventId.minLeadingZeroBits setting while adaptive pow is enabled', () => {
657674
eventLimits.eventId.minLeadingZeroBits = 40
658675
eventLimits.pow = {
659676
enabled: true,

‎test/unit/utils/settings-config.spec.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ describe('settings-config', () => {
131131
expect(issues.some((issue) => issue.path === 'limits.event.pow.floorBits')).to.equal(true)
132132
})
133133

134+
it('rejects a ceilingBits above 256', () => {
135+
const settings = baseSettings()
136+
settings.limits.event.pow.ceilingBits = 300
137+
138+
const issues = validateSettings(settings)
139+
expect(issues.some((issue) => issue.path === 'limits.event.pow.ceilingBits')).to.equal(true)
140+
})
141+
142+
it('accepts a ceilingBits of exactly 256', () => {
143+
const settings = baseSettings()
144+
settings.limits.event.pow.ceilingBits = 256
145+
settings.limits.event.pow.floorBits = 0
146+
147+
const issues = validateSettings(settings)
148+
expect(issues.some((issue) => issue.path === 'limits.event.pow.ceilingBits')).to.equal(false)
149+
})
150+
134151
it('rejects a non-positive periodMs', () => {
135152
const settings = baseSettings()
136153
settings.limits.event.pow.periodMs = 0

0 commit comments

Comments
 (0)