block / block/buzz

relay: NIP-45 COUNT returns an accessible-channel-wide total when a filter's constraint never reaches SQL

Open
#3,373 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## Summary

`filter_fully_pushable` (`crates/buzz-relay/src/handlers/req.rs`) decides whether a COUNT can be answered by a plain SQL `COUNT(*)`. When it returns true, the WebSocket COUNT handler (`handlers/count.rs`) and `POST /count` (`api/bridge.rs`) call `count_events()` with no post-filtering, on the promise in its doc comment of "an exact count without post-filtering".

For two filter shapes that promise does not hold, because the constraint never becomes a SQL predicate. The relay answers with the count of every matching event across all of the caller's accessible channels instead of the correct total. There is no error, just a wrong number.

## Affected shapes

**1. A single `#h` value that is not a channel UUID.** Callers derive `channel_id` by parsing the `#h` value, so a bare NIP-29 group id leaves the query with no channel predicate. `filter_fully_pushable` still reports it as pushable because the `"h"` arm only checks `tag_values.len() > 1`.

```json
{"kinds":[9],"#h":["general"]}
```

**2. An empty value set.** This reaches every generic-tag arm, plus `authors` and `ids`. `filters_match` rejects a present constraint with no matching value, so these match nothing, but `filter_to_query_params` drops the constraint rather than emitting a predicate.

```json
{"kinds":[9],"#t":[]}
{"kinds":[9],"authors":[]}
```

Correct answer in every case above is 0. `kinds: []` is already handled correctly, via the match-nothing sentinel the DB layer short-circuits on.

The REQ path is not affected. It always post-filters through `filters_match`, which enforces these strictly, so REQ and COUNT disagree on the same filter.

## Reproduction

Against a relay on current `main`:

1. Create a channel and post two kind:9 messages to it.
2. `POST /count` with `[{"kinds":[9],"#h":["general"]}]`.

Expected 0. Actual on my local relay was 4: the two messages in my own channel plus two more from an unrelated open channel left over from an earlier run. The same filter over REQ returns nothing, which is the correct behavior.

Substituting `[{"kinds":[9],"#t":[]}]` or `[{"kinds":[9],"authors":[]}]` gives the same shape of wrong answer. A control filter naming the channel's actual UUID (`[{"kinds":[9],"#h":[""]}]`) correctly returns 2, so only the unpushable shapes are affected.

## Suggested fix

Report these as not pushable so they take the existing bounded `query_events` plus `filters_match` fallback, which already computes the correct total. A single `#h` that does parse as a UUID keeps its narrower `channel_id` predicate.

Falling back rather than short-circuiting to 0 matters for the `#h` case: `filters_match` compares the h-tag literal, and ingest only requires a parseable h tag for channel-scoped kinds, so an event can legitimately carry a non-UUID h tag and match.

A cheaper alternative for the empty-set half is to emit the match-nothing sentinel from `filter_to_query_params`, the way `kinds: []` already does, keeping the count exact at no query cost. That touches every consumer of the shared query builder, so it may be better as its own change.

Happy to send a PR.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.