Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/ActiveMonitors.h
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,21 @@ struct ActiveMonitors : NonCopyable {
auto res = allAuthors.try_emplace(Bytes32(f.authors->at(i)));
res.first->second.try_emplace(&f, MonitorItem{m, currEventId});
}
} else if (f.tags.size()) {
} else if (f.tags.size() || f.andTags.size()) {
for (const auto &[tagName, filterSet] : f.tags) {
for (size_t i = 0; i < filterSet.size(); i++) {
auto &tagSpec = getTagSpec(tagName, filterSet.at(i));
auto res = allTags.try_emplace(tagSpec);
res.first->second.try_emplace(&f, MonitorItem{m, currEventId});
}
}
for (const auto &[tagName, filterSet] : f.andTags) {
for (size_t i = 0; i < filterSet.size(); i++) {
auto &tagSpec = getTagSpec(tagName, filterSet.at(i));
auto res = allTags.try_emplace(tagSpec);
res.first->second.try_emplace(&f, MonitorItem{m, currEventId});
}
}
} else if (f.kinds) {
for (size_t i = 0; i < f.kinds->size(); i++) {
auto res = allKinds.try_emplace(f.kinds->at(i));
Expand Down Expand Up @@ -211,7 +218,7 @@ struct ActiveMonitors : NonCopyable {
monSet.erase(&f);
if (monSet.empty()) allAuthors.erase(author);
}
} else if (f.tags.size()) {
} else if (f.tags.size() || f.andTags.size()) {
for (const auto &[tagName, filterSet] : f.tags) {
for (size_t i = 0; i < filterSet.size(); i++) {
auto &tagSpec = getTagSpec(tagName, filterSet.at(i));
Expand All @@ -220,6 +227,14 @@ struct ActiveMonitors : NonCopyable {
if (monSet.empty()) allTags.erase(tagSpec);
}
}
for (const auto &[tagName, filterSet] : f.andTags) {
for (size_t i = 0; i < filterSet.size(); i++) {
auto &tagSpec = getTagSpec(tagName, filterSet.at(i));
auto &monSet = allTags.at(tagSpec);
monSet.erase(&f);
if (monSet.empty()) allTags.erase(tagSpec);
}
}
} else if (f.kinds) {
for (size_t i = 0; i < f.kinds->size(); i++) {
uint64_t kind = f.kinds->at(i);
Expand Down
44 changes: 35 additions & 9 deletions src/DBQuery.h
Original file line number Diff line number Diff line change
Expand Up @@ -120,28 +120,37 @@ struct DBScan : NonCopyable {
}
);
}
} else if (f.tags.size()) {
} else if (f.tags.size() || f.andTags.size()) {
indexDbi = env.dbi_Event__tag;
desc = "Tag";

char tagName = '\0';
bool useAndTag = false;
{
uint64_t numTags = MAX_U64;
uint64_t bestSize = MAX_U64;
for (const auto &[tn, filterSet] : f.tags) {
if (filterSet.size() < numTags) {
numTags = filterSet.size();
if (filterSet.size() < bestSize) {
bestSize = filterSet.size();
tagName = tn;
useAndTag = false;
}
}
// AND tags only need 1 cursor (all values must match, so scanning for one suffices)
for (const auto &[tn, filterSet] : f.andTags) {
if (1 < bestSize) {
bestSize = 1;
tagName = tn;
useAndTag = true;
}
}
}

const auto &filterSet = f.tags.at(tagName);

cursors.reserve(filterSet.size());
for (uint64_t i = 0; i < filterSet.size(); i++) {
if (useAndTag) {
const auto &filterSet = f.andTags.at(tagName);
cursors.reserve(1);
std::string search;
search += tagName;
search += filterSet.at(i);
search += filterSet.at(0);

cursors.emplace_back(
search + std::string(8, '\xFF'),
Expand All @@ -150,6 +159,23 @@ struct DBScan : NonCopyable {
return k.size() == search.size() + 8 && k.starts_with(search) ? KeyMatchResult::Yes : KeyMatchResult::No;
}
);
} else {
const auto &filterSet = f.tags.at(tagName);

cursors.reserve(filterSet.size());
for (uint64_t i = 0; i < filterSet.size(); i++) {
std::string search;
search += tagName;
search += filterSet.at(i);

cursors.emplace_back(
search + std::string(8, '\xFF'),
MAX_U64,
[search](std::string_view k){
return k.size() == search.size() + 8 && k.starts_with(search) ? KeyMatchResult::Yes : KeyMatchResult::No;
}
);
}
}
} else if (f.authors && f.kinds && f.authors->size() * f.kinds->size() < 1'000) {
indexDbi = env.dbi_Event__pubkeyKind;
Expand Down
2 changes: 1 addition & 1 deletion src/apps/relay/RelayWebsocket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ void RelayServer::runWebsocket(ThreadPool<MsgWebsocket>::Thread &thr) {


auto supportedNips = []{
tao::json::value output = tao::json::value::array({ 1, 2, 4, 9, 11, 22, 28, 40, 70 });
tao::json::value output = tao::json::value::array({ 1, 2, 4, 9, 11, 22, 28, 40, 70, 91 });

if (cfg().relay__maxFilterLimitCount > 0) output.push_back(45);
if (cfg().relay__negentropy__enabled) output.push_back(77);
Expand Down
48 changes: 45 additions & 3 deletions src/filters.h
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ struct NostrFilter {
std::optional<FilterSetBytes> authors;
std::optional<FilterSetUint> kinds;
flat_hash_map<char, FilterSetBytes> tags;
flat_hash_map<char, FilterSetBytes> andTags; // NIP-91: AND tag filters

uint64_t since = 0;
uint64_t until = MAX_U64;
Expand Down Expand Up @@ -192,6 +193,29 @@ struct NostrFilter {
} catch (std::exception &e) {
throw herr("error parsing ", k, ": ", e.what());
}
} else if (k.starts_with('&')) {
checkArray();
if (v.get_array().size() == 0) {
neverMatch = true;
continue;
}
numMajorFields++;

try {
if (k.size() == 2) {
char tag = k[1];

if (tag == 'p' || tag == 'e') {
andTags.emplace(tag, FilterSetBytes(v, true, 32, 32));
} else {
andTags.emplace(tag, FilterSetBytes(v, false, 0, MAX_INDEXED_TAG_VAL_SIZE));
}
} else {
throw herr("unindexed tag filter");
}
} catch (std::exception &e) {
throw herr("error parsing ", k, ": ", e.what());
}
} else if (k == "since") {
since = jsonGetUnsigned(v, "error parsing since");
} else if (k == "until") {
Expand All @@ -203,11 +227,11 @@ struct NostrFilter {
}
}

if (tags.size() > 3) throw herr("too many tags in filter"); // O(N^2) in matching, so prevent it from being too large
if (tags.size() + andTags.size() > 3) throw herr("too many tags in filter"); // O(N^2) in matching, so prevent it from being too large

if (limit > maxFilterLimit) limit = maxFilterLimit;

indexOnlyScans = (numMajorFields <= 1) || (numMajorFields == 2 && authors && kinds);
indexOnlyScans = andTags.size() == 0 && ((numMajorFields <= 1) || (numMajorFields == 2 && authors && kinds));
}

bool doesMatchTimes(uint64_t created) const {
Expand Down Expand Up @@ -239,11 +263,29 @@ struct NostrFilter {
if (!foundMatch) return false;
}

// NIP-91: AND tag filters — ALL values must be present
for (const auto &[tag, filt] : andTags) {
for (size_t i = 0; i < filt.size(); i++) {
std::string val = filt.at(i);
bool found = false;

ev.foreachTag([&](char tagName, std::string_view tagVal){
if (tagName == tag && tagVal == val) {
found = true;
return false;
}
return true;
});

if (!found) return false;
}
}

return true;
}

bool isFullDbQuery() {
return !ids && !authors && !kinds && tags.size() == 0;
return !ids && !authors && !kinds && tags.size() == 0 && andTags.size() == 0;
}
};

Expand Down
16 changes: 16 additions & 0 deletions test/dumbFilter.pl
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,21 @@ sub doesMatchSingle {
return 0 if !$found;
}

# NIP-91: AND tag filters — ALL values must be present
foreach my $key (keys %$filter) {
next unless $key =~ /^&(.)$/;
my $tagName = $1;
foreach my $search (@{ $filter->{$key} }) {
my $found;
foreach my $tag (@{ $ev->{tags} }) {
if ($tag->[0] eq $tagName && $tag->[1] eq $search) {
$found = 1;
last;
}
}
return 0 if !$found;
}
}

return 1;
}
8 changes: 8 additions & 0 deletions test/filterFuzzTest.pl
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ sub genRandomFilterGroup {
push @{$f->{'#t'}}, $topics->[int(rand() * @$topics)];
}
}

# NIP-91: AND tag filter
if (rand() < .15) {
$f->{'&t'} = [];
for (1..(rand()*3)+1) {
push @{$f->{'&t'}}, $topics->[int(rand() * @$topics)];
}
}
}

if (rand() < .2) {
Expand Down
71 changes: 71 additions & 0 deletions test/genTestData.pl
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env perl

# Generates synthetic nostr-like events for testing filters including NIP-91 AND tag filters.
# Output is JSONL suitable for `strfry import`.

use strict;
use JSON::XS;

my $numEvents = shift || 5000;

sub fakehex { sprintf("%064x", int(rand() * 2**48) ^ ($_[0] * 7919)) }
my @pubkeys = map { fakehex($_) } (0..19);
my @event_ids = map { fakehex($_ + 1000) } (0..49);
my @topics = qw(bitcoin nos nostr nostrnovember gitlog introductions jb55 damus chat meme cat dog art music);
my @kinds = (0, 1, 3, 4, 6, 7, 30, 42);

srand(42); # deterministic

for my $i (0..$numEvents-1) {
my $pubkey = $pubkeys[int(rand() * @pubkeys)];
my $kind = $kinds[int(rand() * @kinds)];
my $created_at = 1640300802 + int(rand() * 86400 * 365);
my $content = "test event $i";

my @tags;

# Add e-tags
if (rand() < 0.3) {
my $num_e = int(rand() * 3) + 1;
for (1..$num_e) {
push @tags, ["e", $event_ids[int(rand() * @event_ids)]];
}
}

# Add p-tags
if (rand() < 0.3) {
my $num_p = int(rand() * 2) + 1;
for (1..$num_p) {
push @tags, ["p", $pubkeys[int(rand() * @pubkeys)]];
}
}

# Add t-tags (important for NIP-91 AND filter testing)
if (rand() < 0.5) {
my $num_t = int(rand() * 4) + 1;
my %used;
for (1..$num_t) {
my $topic = $topics[int(rand() * @topics)];
next if $used{$topic}++;
push @tags, ["t", $topic];
}
}

# Compute a fake but valid-looking id
my $id = sprintf("%064x", int(rand() * 2**48) ^ ($i * 104729));

# Fake sig (128 hex chars)
my $sig = sprintf("%064x", int(rand() * 2**48)) . sprintf("%064x", int(rand() * 2**48));

my $event = {
id => $id,
pubkey => $pubkey,
created_at => $created_at + 0,
kind => $kind + 0,
tags => \@tags,
content => $content,
sig => $sig,
};

print encode_json($event), "\n";
}
Loading