Skip to content

feat(pow): add relay-load-aware adaptive PoW difficulty - #756

Open
Priyanshubhartistm wants to merge 6 commits into
cameri:mainfrom
Priyanshubhartistm:feat/adaptive-pow-pipeline
Open

feat(pow): add relay-load-aware adaptive PoW difficulty#756
Priyanshubhartistm wants to merge 6 commits into
cameri:mainfrom
Priyanshubhartistm:feat/adaptive-pow-pipeline

Conversation

@Priyanshubhartistm

@Priyanshubhartistm Priyanshubhartistm commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Description

nostream already has basic NIP-13 PoW verification it checks leading zero bits on event IDs and pubkeys against a static minLeadingZeroBits config value. The difficulty is fixed, so it's either too low (useless during a spam flood) or too high (annoying during quiet periods).

This adds an adaptive difficulty layer that scales the required PoW between a configured floor and ceiling based on the observed event rate, tracked per worker process using the same EWMA shape already used by the relay's rate limiter, kept purely in-process rather than Redis-backed.
When enabled, the computed difficulty replaces the static eventId/pubkey minLeadingZeroBits checks entirely one difficulty applied uniformly to both. Disabled by default.

static-mirroring-worker.ts's own PoW check is left untouched it evaluates events already accepted by an upstream relay, a different trust context the adaptive/load-aware framing doesn't obviously apply to.

Related Issue

Closes #755

Motivation and Context

A fixed PoW difficulty can't respond to actual relay load it's either a no-op during quiet periods or an unnecessary burden on legitimate users during a spam flood. This lets operators set a reasonable floor/ceiling and let the relay adjust automatically instead of hand-tuning one static number.

Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4326c2a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
nostream Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls

coveralls commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Coverage Status

coverage: 72.417% (+0.1%) from 72.283% — Priyanshubhartistm:feat/adaptive-pow-pipeline into cameri:main

@phoenix-server phoenix-server left a comment

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.

Review: request changes

Independent two-axis review (correctness/security + conventions). The feature design is sound and well-documented (soft per-worker gate, opt-in, disabled by default, changeset + CONFIGURATION.md present), CI is green, and the tests pin real boundaries. But the core mapping does not do what its name, docs, and defaults promise, and two integration points were missed. Details below; the two blocking items are #1 and #2.

Blocking

1. The difficulty mapping is mis-scaled ~87x at defaults. recordEvent feeds each event to calculateEWMA with step=1 and no time normalization, so rate is a recency-weighted event count (steady state at R sustained events/sec is R * periodMs/1000/ln(2), about 86.6 * R at the default 60000 ms half-life), but getCurrentDifficulty compares it directly against targetEventsPerSecond, a per-second rate. Simulated with the PR code and shipped defaults (target 50, floor 0, ceiling 24): 0.5 sustained events/sec → difficulty 0; 1 event/sec → 18 bits; 2 events/sec → pinned at the 24-bit ceiling. The docs promise the ceiling at 2x target = 100 events/sec. On any live relay this degenerates into a permanent 24-bit requirement (~16.7M hashes/event). The tests pass only because they use same-instant bursts and tiny targets. Suggested fix: normalize before comparing (eps = rate / (periodMs / 1000 / Math.LN2)), or rename the setting to a per-EWMA-window unit, fix the default and docs to match, and add a unit test with time-spaced events (fake timers, one per second) pinning floor-at-target.

2. No floor clamp / no semantic config validation. getCurrentDifficulty clamps at ceilingBits but never at floorBits, and validateSettings only shape-checks against default-settings.yaml. With floorBits: 20, ceilingBits: 10: difficulty 10 at rate 55, 0 at rate 150, -10 at rate 200 — since the check is pow < requiredBits, a negative requirement never rejects, so PoW silently disables exactly when the relay is loaded. Suggested fix: Math.max(config.floorBits, Math.min(config.ceilingBits, scaled)), plus semantic validation (0 <= floorBits <= ceilingBits, periodMs > 0, targetEventsPerSecond > 0) in validateSettings.

Major

3. NIP-11 metadata not updated (src/handlers/request-handlers/root-request-handler.ts, outside this diff): min_pow_difficulty is advertised from the static minLeadingZeroBits only, and restricted_writes is computed from the static bits only. With adaptive enabled and static bits unset, clients see no PoW requirement while events start getting rejected under load. Suggest advertising floorBits when pow.enabled (noting the live requirement can be higher) and counting pow.enabled toward restricted_writes.

4. recordAdaptivePowEvent fires before acceptance, so signature-valid events rejected for PoW, blacklist, auth, NIP-05, or dedup all count toward difficulty. Per-pubkey rate limits do not bound the aggregate, so an attacker rotating pubkeys can push every user to ceiling difficulty with cheap unmined spam, and ordinary client retries inflate the gate. If counting rejected load is deliberate (defensible: it is a CPU-cost proxy, signatures were already verified), please document that semantics in CONFIGURATION.md; otherwise record only accepted events.

Minor / nits (inline below)

  • CONFIGURATION.md: the four limits.event.pow rows break the table's own alphabetical-order rule.
  • src/@types/settings.ts: TSDoc says "on top of" the static checks, but the feature replaces them while enabled.
  • CONFIGURATION.md: "ignored while pow.enabled is true" is only true for the client path; static-mirroring-worker.ts still enforces static bits for mirrored events. Worth a parenthetical for mirror operators.

Verified fine

EWMA cold-start (the lastEventAt = 0 first-call trick), inclusive-at-target floor, Math.ceil rounding (defender-favorable), ceiling clamp, per-worker in-process design (deliberate and disclosed in the changeset), config plumbing and defaults, and the new unit tests otherwise pin real boundaries. The test gap that let #1/#2 through CI: no time-spaced events and no floor-over-ceiling case.

Comment thread src/utils/adaptive-pow.ts Outdated
Comment thread src/utils/adaptive-pow.ts Outdated
Comment thread src/handlers/event-message-handler.ts
Comment thread src/@types/settings.ts Outdated
Comment thread CONFIGURATION.md Outdated
Comment thread CONFIGURATION.md Outdated
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>

@phoenix-server phoenix-server left a comment

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.

Review: request changes (re-review of e454e26)

I re-reviewed the fix commit. All four items from my previous review are resolved — verified by reading the head and running the affected suites locally on e454e26 (74 passing across adaptive-pow.spec.ts / settings-config.spec.ts / root-request-handler.spec.ts, plus 192 in event-message-handler.spec.ts). One new blocking item, two minors, three nits.

Verified resolved

  • #1 units. getCurrentEventsPerSecond divides the EWMA count by periodMs / 1000 / ln2. Reproduced with your own module: sustained 1 / 5 / 25 / 50 eps read back as 1.0058 / 5.0058 / 25.0058 / 50.0058, and the ceiling is reached at exactly 2x target (20 eps vs target 10 → 24 bits). The mapping now does what the docs promise.
  • #2 clamp + semantic validation. Math.max(floorBits, Math.min(ceilingBits, scaled)), plus 0 <= floorBits <= ceilingBits, periodMs > 0, targetEventsPerSecond > 0, with the floor-over-ceiling case pinned by a test. I also confirmed a string floorBits cannot reach the arithmetic: validateShape type-checks against default-settings.yaml and rejects unknown keys, so the "8" + 5 === "85" path is closed.
  • #3 NIP-11. min_pow_difficulty advertises floorBits when enabled, restricted_writes counts pow.enabled, tests pin all three permutations.
  • #4 recording point. Moved past the admission checks; the rate-limited path is pinned by a test.
  • Nits. TSDoc wording, the static-mirroring-worker.ts parenthetical, and the row order (eventId < kind < pow < pubkey) are all fixed.

Blocking

A. Applying the adaptive difficulty to the pubkey check turns the ceiling into an unsatisfiable identity requirement.

canAcceptEvent uses one requiredBits for both axes:

const requiredBits = getAdaptivePowDifficulty(limits.pow)
if (getEventProofOfWork(event.id) < requiredBits) { ... }
if (getPubkeyProofOfWork(event.pubkey) < requiredBits) { ... }

getPubkeyProofOfWork is the leading zero bits of the pubkey itself, so it cannot be mined per-event the way an event id can — it requires generating fresh keypairs until the public key has that prefix. Measured on this box, crypto.createECDH('secp256k1') generates ~2,500 keys/sec (~2,500 is the stdlib rate; native secp256k1 libraries are several times faster), so the shipped default ceiling of 24 bits is ~16.8M keypairs — on the order of an hour of dedicated single-core keygen, and an existing identity can never comply retroactively, no matter how much it mines event ids.

Consequence with the shipped defaults (floorBits: 0, ceilingBits: 24, targetEventsPerSecond: 50): under any sustained accepted load above ~2x target, every event from an author whose key lacks 24 leading zero bits is rejected, with a pow: pubkey difficulty 3<24-style reason the client cannot act on. The advertised semantics — and the point of the feature — is a soft "slow down under load" gate; this is a hard write lockout for normal users until the EWMA decays. It also hands an attacker a cheap lockout: at floorBits: 0 the events that raise the rate only need valid signatures (no mining), so a flood of ~100 signed events/sec pins the gate at ceiling and locks out honest writers for the duration of the decay — and at floor 0 the per-pubkey rate limit binds nothing, since rotating signing keys is free.

This is not covered by "applied to both eventId and pubkey checks" in the description: the reason eventId.minLeadingZeroBits and pubkey.minLeadingZeroBits are separate settings in the first place is that the two axes have wildly different costs. Collapsing them means the ceiling has to be chosen for the cheaper axis, and 24 is exactly the wrong number for the pubkey one.

Suggested fix (either is fine):

  1. Keep the static pubkey.minLeadingZeroBits check as-is while adaptive PoW is enabled — the adaptive requirement applies to eventId only (smallest change, preserves the current contract for the pubkey axis); or
  2. Add a separate pubkeyCeilingBits (defaulting to something a vanity key can plausibly meet, e.g. 8-12) and keep the pubkey requirement clamped to it.

Plus, either way: document the floor-0 amplification caveat in CONFIGURATION.md — with floorBits: 0 the load signal is free to drive, so an operator who wants the gate to cost an attacker anything should set a non-zero floor.

Minor

B. The new comment overstates what is cleared before recording. It says only events that clear "PoW, blacklist, auth, NIP-05, dedup, ..." count, but dedup happens after the recording point: the strategies do eventRepository.create(event) and only then decide count ? '' : 'duplicate:' (default-event-strategy.ts:20 and its siblings). So an already-stored event replayed by a client — or a replaceable-event replay that lands as a no-op — still counts toward the load signal. Either drop dedup from the comment, or record after strategy.execute (which needs the strategy to report whether the write landed).

Nits

C. The rate estimator is biased high by a fixed 1000 * ln2 / (2 * periodMs) events/sec, so the boundaries sit slightly below the configured values. At the default 60s half-life that is +0.0058 eps: a sustained rate of exactly targetEventsPerSecond reads 50.0058 against a target of 50 and yields difficulty 1, not the documented floor (I reproduced this: target 10 → 9 bits at exactly 10 eps; 2x target is effectively reached at ~1.988x). Negligible at the default target (~0.01%), ~0.6% if an operator configures a 1 eps target. Either say "approximately" in the docs, or assert the boundary with a tolerance in the test (the discrete recurrence 1/(1-e^{-lambda*dt}) is what the code actually converges to).

D. targetEventsPerSecond TSDoc still says "same EWMA scale as limits.event.rateLimits". After the normalization those units are real events/sec, whereas the rate limiter's rate setting is a raw EWMA count — the phrasing invites the two to be compared directly, which is exactly the bug that was just fixed. Worth rewording.

E. ceilingBits has no upper bound. NIP-13 difficulty saturates at 256; even 64 makes the gate unsatisfiable in practice. 0 <= floorBits <= ceilingBits means a typo like ceilingBits: 240 validates cleanly and permanently rejects every write. A ceilingBits <= 256 check (or a warning) is cheap insurance.

CI

Every check passes except Coveralls - Integration (coverage decreased 0.05% → 33.798%), which is why the merge state reads UNSTABLE. Not a test failure — the new source lines dilute integration coverage and no integration test covers them. The unit-coverage checks pass (69.56%, +0.2%).

Comment thread src/handlers/event-message-handler.ts Outdated
Comment thread src/utils/adaptive-pow.ts
@phoenix-server

Copy link
Copy Markdown
Collaborator

Maintainer direction on blocking item A: keep the adaptive difficulty on the event id only.

pubkey.minLeadingZeroBits should stay exactly what it is today — a static operator knob, default 0 (disabled), unaffected by limits.event.pow.enabled. So in canAcceptEvent, compute requiredBits from limits.pow for getEventProofOfWork(event.id), and leave the existing static check on getPubkeyProofOfWork(event.pubkey) alone (when adaptive PoW is enabled and the static pubkey value is 0, no pubkey requirement applies).

Why: a pubkey requirement is a one-time, offline identity cost, not a per-event one. It cannot be a load-responsive signal — the mining happens before the client ever talks to the relay, and an existing identity cannot be re-mined to comply. Coupling it to relay load means real users with established keys are blocked for the duration of the load spike no matter what they do, while a spammer pays the key cost once and then reuses it for unlimited events, bounded only by the per-pubkey rate limits that already exist. So it blocks the wrong party.

Operators who genuinely want an identity-level gate keep the existing static limits.event.pubkey.minLeadingZeroBits for it; that is a deliberate, non-adaptive choice.

Please also make the CONFIGURATION.md wording about what happens to the pubkey setting while adaptive PoW is enabled match this behaviour.

The rest of the review stands as written — items B-E and the verified-resolved list are unchanged.

Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>

@phoenix-server phoenix-server left a comment

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.

Review: approve (re-review of 4326c2a)

Re-reviewed the fix commit 106e398 plus the main merge (4326c2a). Blocking item A is resolved, and the branch is current with origin/main. Verified by reading the head and by running the suites in a clean worktree, not by trusting CI alone.

Verified resolved

  • A. Adaptive difficulty is scoped to the event id. canAcceptEvent now gates only getEventProofOfWork(event.id) on limits.pow, and limits.pubkey.minLeadingZeroBits remains an independent static check (src/handlers/event-message-handler.ts:219-238). The two new tests pin exactly the maintainer direction: with adaptive floor 9 and an 8-zero-bit pubkey but static pubkey bits unset, the event is accepted; with static pubkey.minLeadingZeroBits: 16 it is rejected as pow: pubkey difficulty 8<16.
  • B. Recording-point comment. Now states the actual semantics — recording happens after all admission checks, and a duplicate/no-op write still counts (dedup is decided later inside the strategy). CONFIGURATION.md matches.
  • C. Estimator bias. Docs and TSDoc now say "approximately"; the boundary tests use rates safely under target and reach the ceiling via the clamp, so the discrete-recurrence overshoot no longer contradicts the docs.
  • D. Units wording. TSDoc now reads "Event-rate threshold, in real events/sec".
  • E. ceilingBits bound. ceilingBits <= 256 validation added, with tests pinning 256 accepted and 300 rejected.
  • Merge. origin/main is an ancestor of the head; the net diff against main is the 12 feature files with no conflict residue.

Verification on 4326c2a

  • Affected suites: 270 passing (adaptive-pow, settings-config, root-request-handler, event-message-handler).
  • Full unit suite (excluding test/unit/cli, which needs a built dist/): 1796 passing, 0 failing.
  • biome lint clean on the changed files; tsc --noEmit reports only the pre-existing missing @cucumber/cucumber type-lib entry, identical on main.
  • CI on head: everything green except Coveralls - Integration (−0.04% → 33.833%), which is coverage dilution from the new source lines, not a test failure — that check is what keeps the merge state at UNSTABLE.

Nit (non-blocking)

.changeset/adaptive-pow-pipeline.md:9 still says the scaling applies "in place of the existing static minLeadingZeroBits values" — plural. Only eventId.minLeadingZeroBits is replaced now; pubkey.minLeadingZeroBits is still enforced. The PR description carries the same stale sentence ("replaces the static eventId/pubkey minLeadingZeroBits checks entirely one difficulty applied uniformly to both"). Worth correcting both so the released changelog entry does not contradict CONFIGURATION.md.

Approving — the remaining item is documentation wording, not a behavior gap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: adaptive PoW difficulty based on relay load (NIP-13)

3 participants