diff --git a/src/DBQuery.h b/src/DBQuery.h index 7e91f4bd..37ae1d80 100644 --- a/src/DBQuery.h +++ b/src/DBQuery.h @@ -4,9 +4,44 @@ #include "Subscription.h" #include "filters.h" +#include "HyperLogLog.h" #include "events.h" +// Returns HLL offset (8–23) from a filter, or -1 if not eligible for HLL. +inline int computeHllOffset(const NostrFilter &filter) { + if (filter.tags.size() != 1) return -1; + + auto it = filter.tags.begin(); + char tagChar = it->first; + const auto &filterSet = it->second; + if (filterSet.size() != 1) return -1; + + std::string val = filterSet.at(0); + + int offset; + + if (tagChar == 'e' || tagChar == 'p') { + // Stored as 32 raw bytes (hex-decoded) + if (val.size() != 32) return -1; + offset = (((uint8_t)val[16]) >> 4) + 8; + } else { + // Stored as string — must be 64-char hex to derive offset + if (val.size() != 64) return -1; + char c = val[32]; + int nibble; + if (c >= '0' && c <= '9') nibble = c - '0'; + else if (c >= 'a' && c <= 'f') nibble = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') nibble = c - 'A' + 10; + else return -1; + offset = nibble + 8; + } + + if (offset < 8 || offset > 23) return -1; + return offset; +} + + struct DBScan : NonCopyable { struct CandidateEvent { private: @@ -295,7 +330,13 @@ struct DBQuery : NonCopyable { uint64_t totalTime = 0; uint64_t totalWork = 0; - DBQuery(Subscription &sub) : sub(std::move(sub)) {} + int hllOffset = -1; + HyperLogLog hll; + + DBQuery(Subscription &sub) : sub(std::move(sub)) { + if (this->sub.countOnly && this->sub.filterGroup.size() == 1) + hllOffset = computeHllOffset(this->sub.filterGroup.filters[0]); + } DBQuery(const tao::json::value &filter, uint64_t maxLimit = MAX_U64) : sub(Subscription(1, ".", NostrFilterGroup::unwrapped(filter, maxLimit))) {} // If scan is complete, returns true @@ -316,6 +357,11 @@ struct DBQuery : NonCopyable { if (sentEventsFull.find(levId) == sentEventsFull.end()) { sentEventsFull.insert(levId); cb(sub, levId); + + if (hllOffset >= 0) { + auto view = env.lookup_Event(txn, levId); + if (view) hll.addPubkeyBytes((const uint8_t *)PackedEventView(view->buf).pubkey().data(), hllOffset); + } } sentEventsCurr.insert(levId); diff --git a/src/HyperLogLog.h b/src/HyperLogLog.h new file mode 100644 index 00000000..a1ac269e --- /dev/null +++ b/src/HyperLogLog.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + + +struct HyperLogLog { + uint8_t registers[256] = {}; + + void addPubkeyBytes(const uint8_t *pubkey, int offset) { + const uint8_t *x = pubkey + offset; + uint8_t ri = x[0]; // register index + + // Build big-endian uint64 via explicit shifting (no endian-dependent casts) + uint64_t w = (uint64_t)x[0] << 56 | (uint64_t)x[1] << 48 | (uint64_t)x[2] << 40 | (uint64_t)x[3] << 32 + | (uint64_t)x[4] << 24 | (uint64_t)x[5] << 16 | (uint64_t)x[6] << 8 | (uint64_t)x[7]; + + uint8_t zeroBits = clz56(w) + 1; + + if (zeroBits > registers[ri]) { + registers[ri] = zeroBits; + } + } + + std::string encodeHex() const { + static const char hexChars[] = "0123456789abcdef"; + std::string out; + out.resize(512); + for (int i = 0; i < 256; i++) { + out[i * 2] = hexChars[registers[i] >> 4]; + out[i * 2 + 1] = hexChars[registers[i] & 0x0F]; + } + return out; + } + + private: + static uint8_t clz56(uint64_t x) { + uint8_t c = 0; + for (uint64_t m = uint64_t(1) << 55; (m & x) == 0 && m != 0; m >>= 1) { + c++; + } + return c; + } +}; diff --git a/src/QueryScheduler.h b/src/QueryScheduler.h index 72722c82..e3c2d85f 100644 --- a/src/QueryScheduler.h +++ b/src/QueryScheduler.h @@ -6,7 +6,7 @@ struct QueryScheduler : NonCopyable { std::function onEvent; std::function &levIds)> onEventBatch; - std::function onComplete; + std::function onComplete; // If false, then levIds returned to above callbacks can be stale (because they were deleted) // If false, then onEvent's eventPayload will always be "" @@ -101,7 +101,10 @@ struct QueryScheduler : NonCopyable { auto connId = q->sub.connId; removeSub(connId, q->sub.subId); - if (onComplete) onComplete(txn, q->sub, q->sentEventsFull.size()); + std::string hllHex; + if (q->hllOffset >= 0) hllHex = q->hll.encodeHex(); + + if (onComplete) onComplete(txn, q->sub, q->sentEventsFull.size(), std::move(hllHex)); delete q; } else { diff --git a/src/apps/relay/RelayNegentropy.cpp b/src/apps/relay/RelayNegentropy.cpp index 65dbf8de..9292267a 100644 --- a/src/apps/relay/RelayNegentropy.cpp +++ b/src/apps/relay/RelayNegentropy.cpp @@ -137,7 +137,7 @@ void RelayServer::runNegentropy(ThreadPool::Thread &thr) { } }; - queries.onComplete = [&](lmdb::txn &txn, Subscription &sub, uint64_t){ + queries.onComplete = [&](lmdb::txn &txn, Subscription &sub, uint64_t, std::string){ auto *userView = views.findView(sub.connId, sub.subId); if (!userView) return; diff --git a/src/apps/relay/RelayReqWorker.cpp b/src/apps/relay/RelayReqWorker.cpp index 5478c522..de2c613e 100644 --- a/src/apps/relay/RelayReqWorker.cpp +++ b/src/apps/relay/RelayReqWorker.cpp @@ -11,7 +11,7 @@ void RelayServer::runReqWorker(ThreadPool::Thread &thr) { sendEvent(sub.connId, sub.subId, decodeEventPayload(txn, decomp, eventPayload, nullptr, nullptr)); }; - queries.onComplete = [&](lmdb::txn &, Subscription &sub, uint64_t total){ + queries.onComplete = [&](lmdb::txn &, Subscription &sub, uint64_t total, std::string hllHex){ if (sub.countOnly) { bool limited = false; @@ -25,6 +25,7 @@ void RelayServer::runReqWorker(ThreadPool::Thread &thr) { }); if (limited) countBody["limited"] = true; + if (hllHex.size()) countBody["hll"] = std::move(hllHex); sendToConn(sub.connId, tao::json::to_string(tao::json::value::array({ "COUNT", sub.subId.str(), countBody }))); } else { diff --git a/test/cfgs/nip45HllTest.conf b/test/cfgs/nip45HllTest.conf new file mode 100644 index 00000000..cd4091f7 --- /dev/null +++ b/test/cfgs/nip45HllTest.conf @@ -0,0 +1,9 @@ +db = "./strfry-db-nip45-hll-test/" + +events { + rejectEventsOlderThanSeconds = 9999999999 +} + +relay { + port = 40563 +} diff --git a/test/hll_reference.py b/test/hll_reference.py new file mode 100644 index 00000000..322013e3 --- /dev/null +++ b/test/hll_reference.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +HyperLogLog reference implementation — exact port of go-nostr nip45/hyperloglog. + +Used as oracle for C++ unit tests and E2E test vector generation. +""" + +import struct + + +def clz56(x: int) -> int: + """Count leading zeros in the lower 56 bits of x (direct port of go-nostr helpers.go).""" + c = 0 + m = 1 << 55 + while (m & x) == 0 and m != 0: + c += 1 + m >>= 1 + return c + + +def hll_add(registers: bytearray, pubkey_bytes: bytes, offset: int): + """Update HLL registers from a 32-byte pubkey at the given offset (port of AddBytes).""" + x = pubkey_bytes[offset:offset + 8] + j = x[0] # register index + w = struct.unpack('>Q', x)[0] # big-endian uint64 + zero_bits = clz56(w) + 1 + if zero_bits > registers[j]: + registers[j] = zero_bits + + +def hll_encode(registers: bytearray) -> str: + """Encode 256 registers as 512-char lowercase hex string.""" + return registers.hex() + + +def compute_offset_from_hex(tag_value_hex: str) -> int: + """Compute HLL offset from a 64-char hex tag value (e.g. for #e/#p filters).""" + return int(tag_value_hex[32], 16) + 8 + + +def compute_offset_from_bytes(raw_bytes: bytes) -> int: + """Compute HLL offset from 32 raw bytes (strfry internal for #e/#p tags).""" + return ((raw_bytes[16] >> 4) & 0xF) + 8 + + +if __name__ == "__main__": + # Generate test vectors for C++ unit test + + # Known pubkeys (32 bytes each) + pubkeys_hex = [ + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13", + ] + pubkeys = [bytes.fromhex(h) for h in pubkeys_hex] + + offset = 12 # arbitrary test offset + + print(f"Test offset: {offset}") + print(f"Number of pubkeys: {len(pubkeys)}") + print() + + registers = bytearray(256) + for i, pk in enumerate(pubkeys): + hll_add(registers, pk, offset) + encoded = hll_encode(registers) + print(f"After adding pubkey[{i}] ({pubkeys_hex[i][:16]}...):") + print(f" hex = {encoded}") + + print() + print(f"Final hex ({len(hll_encode(registers))} chars): {hll_encode(registers)}") + + # Also test offset computation + test_tag = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + off = compute_offset_from_hex(test_tag) + raw = bytes.fromhex(test_tag) + off2 = compute_offset_from_bytes(raw) + print(f"\nOffset from hex tag '{test_tag[32]}' (pos 32): {off}") + print(f"Offset from raw byte[16]=0x{raw[16]:02x}: {off2}") + assert off == off2, "Offset mismatch!" + print("Offset computation consistent.") diff --git a/test/hll_unit_test.cpp b/test/hll_unit_test.cpp new file mode 100644 index 00000000..d5ba2399 --- /dev/null +++ b/test/hll_unit_test.cpp @@ -0,0 +1,90 @@ +#include +#include +#include +#include + +#include "../src/HyperLogLog.h" + +static uint8_t hexVal(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return 0; +} + +static void hexToBytes(const char *hex, uint8_t *out, size_t outLen) { + for (size_t i = 0; i < outLen; i++) { + out[i] = (hexVal(hex[i * 2]) << 4) | hexVal(hex[i * 2 + 1]); + } +} + +int main() { + // Same test vectors as test/hll_reference.py + const char *pubkeysHex[] = { + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd13", + }; + + // Expected hex output after adding each pubkey incrementally (from Python oracle, offset=12) + const char *expectedHex[] = { + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000", + "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000", + }; + + int offset = 12; + int passed = 0; + int failed = 0; + + HyperLogLog hll; + + for (int i = 0; i < 4; i++) { + uint8_t pubkey[32]; + hexToBytes(pubkeysHex[i], pubkey, 32); + + hll.addPubkeyBytes(pubkey, offset); + std::string hex = hll.encodeHex(); + + if (hex == expectedHex[i]) { + printf(" PASS after pubkey[%d]\n", i); + passed++; + } else { + printf(" FAIL after pubkey[%d]\n", i); + printf(" expected: %.64s...\n", expectedHex[i]); + printf(" got: %.64s...\n", hex.c_str()); + failed++; + } + } + + // Test that all-zero registers produce all-zero hex + { + HyperLogLog empty; + std::string hex = empty.encodeHex(); + std::string expected(512, '0'); + if (hex == expected) { + printf(" PASS empty registers\n"); + passed++; + } else { + printf(" FAIL empty registers\n"); + failed++; + } + } + + // Test encodeHex length + { + std::string hex = hll.encodeHex(); + if (hex.size() == 512) { + printf(" PASS encodeHex length == 512\n"); + passed++; + } else { + printf(" FAIL encodeHex length == %zu\n", hex.size()); + failed++; + } + } + + printf("\n%d passed, %d failed\n", passed, failed); + return failed == 0 ? 0 : 1; +} diff --git a/test/nip45_hll_test.py b/test/nip45_hll_test.py new file mode 100644 index 00000000..e50f413b --- /dev/null +++ b/test/nip45_hll_test.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +NIP-45 HyperLogLog COUNT End-to-End Tests for strfry relay. + +Requires: pip install secp256k1 websockets + +Tests HLL data in COUNT responses against a live strfry relay: + - Publish events from known pubkeys, COUNT with tag filters + - Verify response includes count and hll (512-char hex) + - Verify hll matches Python oracle computation + - Verify non-eligible filters omit hll + - Verify 0-result queries return all-zero hll +""" + +import asyncio +import hashlib +import json +import os +import signal +import shutil +import socket +import struct +import subprocess +import sys +import time + +import secp256k1 +import websockets + + +# ── HLL reference (inline from hll_reference.py) ──────────────────────────── + +def clz56(x: int) -> int: + c = 0 + m = 1 << 55 + while (m & x) == 0 and m != 0: + c += 1 + m >>= 1 + return c + +def hll_add(registers: bytearray, pubkey_bytes: bytes, offset: int): + x = pubkey_bytes[offset:offset + 8] + j = x[0] + w = struct.unpack('>Q', x)[0] + zero_bits = clz56(w) + 1 + if zero_bits > registers[j]: + registers[j] = zero_bits + +def hll_encode(registers: bytearray) -> str: + return registers.hex() + +def compute_offset_from_hex(tag_value_hex: str) -> int: + return int(tag_value_hex[32], 16) + 8 + + +# ── Keys ──────────────────────────────────────────────────────────────────── + +KEY_A_SEC = bytes.fromhex("c1eee22f68dc218d98263cfecb350db6fc6b3e836b47423b66c62af7ae3e32bb") +KEY_A_PUB = None # computed at startup + +KEY_B_SEC = bytes.fromhex("a0b459d9ff90e30dc9d1749b34c4401dfe80ac2617c7732925ff994e8d5203ff") +KEY_B_PUB = None + +KEY_C_SEC = bytes.fromhex("3fa463bf3a6b0e4c8d9a1e7b2f5c6d8e0a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d") +KEY_C_PUB = None + +KEY_D_SEC = bytes.fromhex("5fa463bf3a6b0e4c8d9a1e7b2f5c6d8e0a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6e") +KEY_D_PUB = None + + +# ── Nostr helpers ─────────────────────────────────────────────────────────── + +def compute_pubkey(sec_bytes: bytes) -> str: + pk = secp256k1.PrivateKey(sec_bytes) + return pk.pubkey.serialize()[1:].hex() + + +def make_event(sec_key: bytes, pubkey: str, kind: int, content: str, + tags: list = None, created_at: int = None) -> dict: + if tags is None: + tags = [] + if created_at is None: + created_at = int(time.time()) + event = { + "pubkey": pubkey, + "created_at": created_at, + "kind": kind, + "tags": tags, + "content": content, + } + serialized = json.dumps( + [0, event["pubkey"], event["created_at"], event["kind"], + event["tags"], event["content"]], + separators=(",", ":"), ensure_ascii=False, + ) + event["id"] = hashlib.sha256(serialized.encode()).hexdigest() + pk = secp256k1.PrivateKey(sec_key) + sig = pk.schnorr_sign(bytes.fromhex(event["id"]), bip340tag=None, raw=True) + event["sig"] = sig.hex() + return event + + +async def send_event(ws, event): + await ws.send(json.dumps(["EVENT", event])) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + if resp[0] == "OK" and resp[1] == event["id"]: + return resp + raise TimeoutError(f"no OK for event {event['id']} within 10s") + + +async def count_events(ws, filt, sub_id="cnt"): + """Send a COUNT request, return the response body dict.""" + await ws.send(json.dumps(["COUNT", sub_id, filt])) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + if resp[0] == "COUNT" and resp[1] == sub_id: + return resp[2] + raise TimeoutError(f"no COUNT response for {sub_id} within 10s") + + +async def connect(port=40563): + return await websockets.connect(f"ws://127.0.0.1:{port}") + + +# ── Process management ────────────────────────────────────────────────────── + +STRFRY = "./strfry" +DB_DIR = "./strfry-db-nip45-hll-test" +CONF = "test/cfgs/nip45HllTest.conf" +PORT = 40563 + + +def clean_db(): + if os.path.exists(DB_DIR): + shutil.rmtree(DB_DIR) + os.makedirs(DB_DIR, exist_ok=True) + + +def start_relay(conf=CONF): + try: + s = socket.create_connection(("127.0.0.1", PORT), timeout=0.1) + s.close() + raise RuntimeError(f"port {PORT} already in use before starting relay") + except (ConnectionRefusedError, OSError): + pass + + proc = subprocess.Popen( + [STRFRY, "--config", conf, "relay"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + for _ in range(50): + if proc.poll() is not None: + raise RuntimeError(f"strfry exited early with code {proc.returncode}") + try: + s = socket.create_connection(("127.0.0.1", PORT), timeout=0.1) + s.close() + return proc + except (ConnectionRefusedError, OSError): + time.sleep(0.1) + proc.kill() + proc.wait() + raise RuntimeError("strfry relay did not start within 5s") + + +def stop_relay(proc): + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + + +# ── Test infrastructure ───────────────────────────────────────────────────── + +passed = 0 +failed = 0 +errors = [] + + +def report(name, ok, detail=""): + global passed, failed, errors + if ok: + passed += 1 + print(f" PASS {name}") + else: + failed += 1 + errors.append((name, detail)) + print(f" FAIL {name} -- {detail}") + + +async def run_with_fresh_relay(test_fn, conf=CONF): + clean_db() + relay = start_relay(conf) + try: + await test_fn() + finally: + stop_relay(relay) + + +# ── Tests ─────────────────────────────────────────────────────────────────── + +async def test_hll_e_tag_reactions(): + """Publish kind-7 reactions from N pubkeys tagging a target event, verify HLL.""" + ws = await connect() + try: + # Create a target event + target = make_event(KEY_A_SEC, KEY_A_PUB, 1, "target post", created_at=1000) + await send_event(ws, target) + target_id = target["id"] + + # Post reactions from multiple pubkeys + keys = [ + (KEY_A_SEC, KEY_A_PUB), + (KEY_B_SEC, KEY_B_PUB), + (KEY_C_SEC, KEY_C_PUB), + (KEY_D_SEC, KEY_D_PUB), + ] + + for i, (sec, pub) in enumerate(keys): + ev = make_event(sec, pub, 7, "+", + tags=[["e", target_id]], created_at=2000 + i) + r = await send_event(ws, ev) + assert r[2] is True, f"event rejected: {r}" + + # COUNT with #e filter + result = await count_events(ws, {"#e": [target_id], "kinds": [7]}) + + report("HLL #e: count is correct", + result.get("count") == len(keys), + f"count={result.get('count')}, expected={len(keys)}") + + hll_hex = result.get("hll", "") + report("HLL #e: hll field present and 512 chars", + len(hll_hex) == 512, + f"hll length={len(hll_hex)}") + + # Compute expected HLL + offset = compute_offset_from_hex(target_id) + expected_regs = bytearray(256) + for _, pub in keys: + hll_add(expected_regs, bytes.fromhex(pub), offset) + expected_hex = hll_encode(expected_regs) + + report("HLL #e: hll matches oracle", + hll_hex == expected_hex, + f"got={hll_hex[:40]}... expected={expected_hex[:40]}...") + finally: + await ws.close() + + +async def test_hll_p_tag_follows(): + """Publish kind-3 follow events, verify HLL for #p filter.""" + ws = await connect() + try: + # Post follow events from multiple pubkeys all tagging KEY_A_PUB + keys = [ + (KEY_B_SEC, KEY_B_PUB), + (KEY_C_SEC, KEY_C_PUB), + (KEY_D_SEC, KEY_D_PUB), + ] + + for i, (sec, pub) in enumerate(keys): + ev = make_event(sec, pub, 3, "", + tags=[["p", KEY_A_PUB]], created_at=3000 + i) + r = await send_event(ws, ev) + assert r[2] is True, f"event rejected: {r}" + + # COUNT with #p filter + result = await count_events(ws, {"#p": [KEY_A_PUB], "kinds": [3]}) + + report("HLL #p: count is correct", + result.get("count") == len(keys), + f"count={result.get('count')}, expected={len(keys)}") + + hll_hex = result.get("hll", "") + report("HLL #p: hll field present and 512 chars", + len(hll_hex) == 512, + f"hll length={len(hll_hex)}") + + # Compute expected HLL + offset = compute_offset_from_hex(KEY_A_PUB) + expected_regs = bytearray(256) + for _, pub in keys: + hll_add(expected_regs, bytes.fromhex(pub), offset) + expected_hex = hll_encode(expected_regs) + + report("HLL #p: hll matches oracle", + hll_hex == expected_hex, + f"got={hll_hex[:40]}... expected={expected_hex[:40]}...") + finally: + await ws.close() + + +async def test_no_hll_without_tags(): + """Non-eligible filter (no tags) should NOT include hll in response.""" + ws = await connect() + try: + ev = make_event(KEY_A_SEC, KEY_A_PUB, 1, "hello", created_at=4000) + await send_event(ws, ev) + + result = await count_events(ws, {"authors": [KEY_A_PUB]}) + + report("No HLL: count present", + result.get("count") is not None, + f"result={result}") + + report("No HLL: hll absent for non-tag filter", + "hll" not in result, + f"result keys={list(result.keys())}") + finally: + await ws.close() + + +async def test_hll_zero_results(): + """COUNT with 0 matching events should return all-zero hll.""" + ws = await connect() + try: + # Use a target that has no events + fake_id = "0000000000000000000000000000000000000000000000000000000000000000" + result = await count_events(ws, {"#e": [fake_id], "kinds": [7]}) + + report("Zero results: count is 0", + result.get("count") == 0, + f"count={result.get('count')}") + + hll_hex = result.get("hll", "") + expected_zero = "0" * 512 + + report("Zero results: hll is all zeros", + hll_hex == expected_zero, + f"hll={hll_hex[:40]}...") + finally: + await ws.close() + + +async def test_no_hll_multiple_tags(): + """Filter with multiple tag types should NOT include hll.""" + ws = await connect() + try: + target = make_event(KEY_A_SEC, KEY_A_PUB, 1, "target", created_at=5000) + await send_event(ws, target) + + ev = make_event(KEY_B_SEC, KEY_B_PUB, 7, "+", + tags=[["e", target["id"]], ["p", KEY_A_PUB]], + created_at=5001) + await send_event(ws, ev) + + # COUNT with both #e and #p — not eligible for HLL + result = await count_events(ws, {"#e": [target["id"]], "#p": [KEY_A_PUB]}) + + report("Multi-tag: hll absent", + "hll" not in result, + f"result keys={list(result.keys())}") + finally: + await ws.close() + + +# ── Runner ────────────────────────────────────────────────────────────────── + +async def run_tests(): + global passed, failed + + print("\n=== NIP-45 HLL COUNT Tests ===\n") + + await run_with_fresh_relay(test_hll_e_tag_reactions) + await run_with_fresh_relay(test_hll_p_tag_follows) + await run_with_fresh_relay(test_no_hll_without_tags) + await run_with_fresh_relay(test_hll_zero_results) + await run_with_fresh_relay(test_no_hll_multiple_tags) + + # Cleanup + if os.path.exists(DB_DIR): + shutil.rmtree(DB_DIR) + + print(f"\n{'='*50}") + print(f" {passed} passed, {failed} failed") + if errors: + print(f"\n Failures:") + for name, detail in errors: + print(f" - {name}: {detail}") + print(f"{'='*50}\n") + return failed == 0 + + +if __name__ == "__main__": + KEY_A_PUB = compute_pubkey(KEY_A_SEC) + KEY_B_PUB = compute_pubkey(KEY_B_SEC) + KEY_C_PUB = compute_pubkey(KEY_C_SEC) + KEY_D_PUB = compute_pubkey(KEY_D_SEC) + ok = asyncio.run(run_tests()) + sys.exit(0 if ok else 1)