Skip to content

relay: NIP-11 advertises max_limit 10,000 but enforces 1,000 — silent truncation for clients that trust it #3757

Description

@tlongwell-block

The relay advertises max_limit: 10_000 in its NIP-11 document but enforces an effective websocket page limit of 1,000 — a 10x discrepancy. NIP-11 defines the field as "the relay server will clamp each filter's limit value to this number… with this number, you can know that there are additional results" (11.md:129), so a client that trusts it can silently lose data.

The three layers

Layer Value Source
NIP-11 advertised max_limit 10,000 crates/buzz-relay/src/nip11.rs:106
WS REQ clamp 2,000 crates/buzz-relay/src/handlers/req.rs:25 (MAX_HISTORICAL_LIMIT), applied at :881-882
DB clamp 1,000 crates/buzz-db/src/event.rs:347let clamp = q.max_limit.unwrap_or(1000)

EventQuery::max_limit is left None on the websocket REQ path (it is set only by the COUNT fallback, req.rs:755), so the DB default of 1,000 applies. Effective page limit is min(requested, 2000, 1000) = 1,000.

The HTTP bridge POST /query path is the same: it builds its query through crate::handlers::req::build_event_query_from_filter (crates/buzz-relay/src/api/bridge.rs:1215) and likewise leaves max_limit unset.

Why it matters

A paginating client that reads NIP-11 and asks for limit: 10000 receives 1,000 events. Seeing a page an order of magnitude shorter than the limit it requested and than the cap the relay advertised, it concludes the query is exhausted — and silently drops everything older. There is no error and no truncation signal.

This is the exact failure mode that NIP-MP's pagination contract (#3163) names as condition 2: "The relay exposes the exact effective page limit it enforces… If the advertised cap differs from the enforced one, 'shorter than the effective limit' is undecidable by the client." Writing that condition down is what surfaced this.

Suggested fix

Advertise what is enforced. Either set NIP-11 max_limit to the true effective value, or derive it from the same constant the enforcing paths use so the two cannot drift again. The second is preferable — a hardcoded 10_000 next to a MAX_HISTORICAL_LIMIT of 2_000 and a DB default of 1_000 is three sources of truth for one number.

Note the two enforced clamps are themselves redundant: the WS clamp of 2,000 can never bind, because the DB clamp of 1,000 is always tighter on that path.

Found while reviewing #3163 (NIP-MP). Not a blocker for that PR — its spec text is scoped correctly and does not claim Buzz satisfies the contract — but it is a live silent-data-loss path for any client that trusts the advertised limit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions