From 06eab1b007e2811f00d001ad26c2e47af3761773 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan <43773168+PatMulligan@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:35:29 +0200 Subject: [PATCH 1/6] fix: keep every filter of a multi-filter REQ _handle_request dropped the subscription's existing filters before adding each filter of a REQ, so a REQ carrying several filters ended up with only its last one registered. Remove the old filters once per REQ instead, before the loop, since a REQ replaces the subscription as a whole. Also invert _can_add_filter so its name matches what it returns. The old version returned "limit exceeded" and the caller tested for that, so behaviour is unchanged; the tests pin it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt (cherry picked from commit 37331b14847cfb5d43f8e97b36a6f52d9b86abae) --- tests/test_req_filters.py | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_req_filters.py diff --git a/tests/test_req_filters.py b/tests/test_req_filters.py new file mode 100644 index 0000000..833862b --- /dev/null +++ b/tests/test_req_filters.py @@ -0,0 +1,53 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ..relay.client_connection import NostrClientConnection +from ..relay.relay import RelaySpec + +RELAY_ID = "relay_req_filters" + + +def _connection(spec: RelaySpec | None = None) -> NostrClientConnection: + conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock()) + conn.get_client_config = lambda: spec or RelaySpec() + conn._send_msg = AsyncMock() # type: ignore[method-assign] + return conn + + +def _subscriptions(conn: NostrClientConnection) -> list[str | None]: + return [f.subscription_id for f in conn.filters] + + +@pytest.mark.asyncio +async def test_multi_filter_req_keeps_every_filter(): + conn = _connection() + + await conn._handle_message(["REQ", "sub", {"kinds": [1]}, {"kinds": [3]}]) + + assert _subscriptions(conn) == ["sub", "sub"] + assert sorted(f.kinds for f in conn.filters) == [[1], [3]] + + +@pytest.mark.asyncio +async def test_new_req_replaces_the_subscription(): + conn = _connection() + await conn._handle_message(["REQ", "sub", {"kinds": [1]}, {"kinds": [3]}]) + await conn._handle_message(["REQ", "other", {"kinds": [7]}]) + + await conn._handle_message(["REQ", "sub", {"kinds": [0]}]) + + assert _subscriptions(conn) == ["other", "sub"] + assert [f.kinds for f in conn.filters] == [[7], [0]] + + +@pytest.mark.asyncio +async def test_filter_cap_is_enforced(): + conn = _connection(RelaySpec(maxClientFilters=2)) + + await conn._handle_message(["REQ", "a", {"kinds": [1]}]) + await conn._handle_message(["REQ", "b", {"kinds": [1]}]) + responses = await conn._handle_message(["REQ", "c", {"kinds": [1]}]) + + assert _subscriptions(conn) == ["a", "b"] + assert responses == [["NOTICE", "Maximum number of filters (2) exceeded."]] -- 2.55.0 From d2350f907da7b1d6dbcd5c826b92273783cca774 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan <43773168+PatMulligan@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:26:33 +0200 Subject: [PATCH 2/6] test: wait for messages instead of sleeping in the client flow test_alice_and_bob paused for fixed 0.1-0.5 s between wiring events and asserting on the replies. On a slow runner the replies were not there yet; on a fast one the next step ran before the previous event was stored. Both show up as spurious failures (CI on #45, 2026-09-13). Give the mock socket a wait_for_messages(count) helper and wait for the expected number of messages at each step instead. Exact-count assertions stay, so extra messages still fail. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt (cherry picked from commit deb6510193b68f343c2f7845c615634beb020911) --- tests/test_clients.py | 49 +++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/tests/test_clients.py b/tests/test_clients.py index e65bc60..5d1100a 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -37,6 +37,18 @@ class MockWebSocket(WebSocket): async def send_text(self, data: str): self.sent_messages.append(data) + async def wait_for_messages(self, count: int, timeout: float = 5.0): + """Wait until at least `count` messages were sent to this socket.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while len(self.sent_messages) < count: + if loop.time() > deadline: + raise AssertionError( + f"Expected {count} messages, got {len(self.sent_messages)} " + f"after {timeout}s: {self.sent_messages}" + ) + await asyncio.sleep(0.01) + async def wire_mock_data(self, data: dict): await self.fake_wire.put(dumps(data)) @@ -98,7 +110,7 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): await ws_alice.wire_mock_data(alice["post01"]) await ws_alice.wire_mock_data(alice["post01"]) await ws_alice.wire_mock_data(alice["meta_update"]) - await asyncio.sleep(0.5) + await ws_alice.wait_for_messages(4) assert ( len(ws_alice.sent_messages) == 4 @@ -113,8 +125,6 @@ async def alice_wires_meta_and_post01(ws_alice: MockWebSocket): alice["meta_update_response"] ), "Alice: Expected confirmation for meta update" - await asyncio.sleep(0.1) - async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): ws_bob.sent_messages.clear() @@ -122,8 +132,7 @@ async def bob_wires_meta_and_folows_alice(ws_bob: MockWebSocket): await ws_bob.wire_mock_data(bob["meta"]) await ws_bob.wire_mock_data(bob["request_meta_alice"]) await ws_bob.wire_mock_data(bob["request_posts_alice"]) - - await asyncio.sleep(0.5) + await ws_bob.wait_for_messages(5) assert len(ws_bob.sent_messages) == 5, "Bob: Expected 5 confirmations to be sent" assert ws_bob.sent_messages[0] == dumps( @@ -149,9 +158,9 @@ async def bob_wires_contact_list(ws_alice: MockWebSocket, ws_bob: MockWebSocket) await ws_bob.wire_mock_data(bob["contact_list_create"]) await ws_bob.wire_mock_data(bob["contact_list_update"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(2) await ws_alice.wire_mock_data(alice["subscribe_to_bob_contact_list"]) - await asyncio.sleep(0.1) + await ws_alice.wait_for_messages(2) assert ( len(ws_bob.sent_messages) == 2 @@ -181,7 +190,8 @@ async def alice_wires_post02_____bob_is_notified( ws_alice.sent_messages.clear() await ws_alice.wire_mock_data(alice["post02"]) - await asyncio.sleep(0.1) + await ws_alice.wait_for_messages(1) + await ws_bob.wait_for_messages(1) assert ws_alice.sent_messages[0] == dumps( alice["post02_response_ok"] @@ -198,9 +208,9 @@ async def bob_likes_post01_____alice_subscribes_and_receives_notifications( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["like_post01"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(1) await ws_alice.wire_mock_data(alice["subscribe_reactions_to_me"]) - await asyncio.sleep(0.1) + await ws_alice.wait_for_messages(2) assert ( len(ws_alice.sent_messages) == 2 @@ -227,7 +237,8 @@ async def bob_likes_and_comments_____alice_receives_notifications( await ws_bob.wire_mock_data(bob["like_post02"]) await ws_bob.wire_mock_data(bob["comment_on_alice_post01"]) - await asyncio.sleep(0.5) + await ws_bob.wait_for_messages(2) + await ws_alice.wait_for_messages(2) assert ( len(ws_bob.sent_messages) == 2 @@ -262,7 +273,8 @@ async def bob_writes_to_alice(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["direct_message01"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(1) + await ws_alice.wait_for_messages(1) assert ( len(ws_bob.sent_messages) == 1 @@ -287,7 +299,7 @@ async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): ws_bob.sent_messages.clear() await ws_alice.wire_mock_data(alice["direct_message01"]) - await asyncio.sleep(0.1) + await ws_alice.wait_for_messages(1) assert ( len(ws_alice.sent_messages) == 1 @@ -298,7 +310,7 @@ async def alice_writes_to_bob(ws_alice: MockWebSocket, ws_bob: MockWebSocket): assert len(ws_bob.sent_messages) == 0, "Bob: no subscription, no message" await ws_bob.wire_mock_data(bob["subscribe_to_direct_messages"]) - await asyncio.sleep(0.5) + await ws_bob.wait_for_messages(2) assert ( len(ws_bob.sent_messages) == 2 @@ -321,7 +333,7 @@ async def alice_deletes_post01__bob_is_notified( ): ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["request_posts_alice"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(3) assert ( len(ws_bob.sent_messages) == 3 ), "Bob: Expected two posts from Alice plus and EOSE" @@ -330,9 +342,10 @@ async def alice_deletes_post01__bob_is_notified( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["subscribe_to_delete_from_alice"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(1) await ws_alice.wire_mock_data(alice["delete_post01"]) - await asyncio.sleep(0.1) + await ws_alice.wait_for_messages(1) + await ws_bob.wait_for_messages(2) assert ( len(ws_alice.sent_messages) == 1 @@ -353,7 +366,7 @@ async def alice_deletes_post01__bob_is_notified( ws_bob.sent_messages.clear() await ws_bob.wire_mock_data(bob["request_posts_alice"]) - await asyncio.sleep(0.1) + await ws_bob.wait_for_messages(2) assert ( len(ws_bob.sent_messages) == 2 ), "Bob: Expected one posts from Alice plus and EOSE" -- 2.55.0 From fd19acff972fc8b06a7f67d518decc48b4a1bca6 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan <43773168+PatMulligan@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:28:11 +0200 Subject: [PATCH 3/6] fix(nip09): honour `a` tags in deletion requests A kind-5 request could only name events by `e` tag. Worse, one that carried `a` tags and no `e` tags built a filter with an empty id list, which matched every event by that author and marked them all deleted. Handle `a` tags per NIP-09: parse `kind:pubkey:d` (the `d` value may contain ':'), require the pubkey to be the request author and the kind to be replaceable or addressable, and remove only versions up to the request's `created_at` so a later re-publication survives. An empty `d` addresses a replaceable kind. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt (cherry picked from commit e695f9c66881d3d520d40f0cd3e9c95d9435938c) --- README.md | 4 +- relay/client_connection.py | 95 ++++++++++++------------- relay/event.py | 3 + relay/filter.py | 4 ++ tests/test_nip09.py | 137 +++++++++++++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 52 deletions(-) create mode 100644 tests/test_nip09.py diff --git a/README.md b/README.md index d5f7ade..b56db57 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ - [x] **NIP-04**: Encrypted Direct Message - if `AUTH` enabled: send only to the intended target - [x] **NIP-09**: Event Deletion - - [x] 'e' tags: Delete regular events by event ID - - [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier) + - [x] `e` tags: delete events by id + - [x] `a` tags: delete replaceable / addressable events by `kind:pubkey:d` - [x] **NIP-11**: Relay Information Document - > **Note**: the endpoint is NOT on the root level of the domain. It also includes a path (eg https://lnbits.link/nostrrelay/) - [ ] **NIP-12**: Generic Tag Queries diff --git a/relay/client_connection.py b/relay/client_connection.py index fae522e..1480e2a 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -229,66 +229,61 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - # NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) + """NIP-09: `e` tags name events by id, `a` tags name replaceable or + addressable events by `kind:pubkey:d`. Only the author's own events + are touched; for `a` tags only versions up to the deletion request's + `created_at` are removed, so a later re-publication survives.""" + ids_to_delete: list[str] = [] - # Get event IDs from 'e' tags (for regular events) event_ids = [t[1] for t in event.tags if t[0] == "e"] - - # Get event addresses from 'a' tags (for parameterized replaceable events) - event_addresses = [t[1] for t in event.tags if t[0] == "a"] - - ids_to_delete = [] - - # Handle regular event deletions (e tags) if event_ids: nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids) events_to_delete = await get_events(self.relay_id, nostr_filter, False) - ids_to_delete.extend( - [e.id for e in events_to_delete if not e.is_delete_event] + ids_to_delete += [e.id for e in events_to_delete if not e.is_delete_event] + + for address in (t[1] for t in event.tags if t[0] == "a"): + address_filter = self._address_filter(address, event.pubkey) + if not address_filter: + logger.debug(f"NIP-09: ignoring address '{address}'") + continue + # An addressable kind with an empty `d` names the version whose + # `d` tag is empty or missing, which no tag join can express, so + # fetch tags and filter here. + empty_d = address_filter.is_addressable and not address_filter.d + events_to_delete = await get_events( + self.relay_id, address_filter, include_tags=empty_d ) + ids_to_delete += [ + e.id + for e in events_to_delete + if e.created_at <= event.created_at + and (not empty_d or not e.has_tag_value_other_than("d", "")) + ] - # Handle parameterized replaceable event deletions (a tags) - if event_addresses: - for addr in event_addresses: - # Parse address format: kind:pubkey:d-tag - parts = addr.split(":") - if len(parts) == 3: - kind_str, addr_pubkey, d_tag = parts - try: - kind = int(kind_str) - # Only delete if the address pubkey matches the deletion event author - if addr_pubkey == event.pubkey: - # NOTE: Use "#d" alias, not "d" directly (Pydantic Field alias) - nostr_filter = NostrFilter( - authors=[addr_pubkey], - kinds=[kind], - **{"#d": [d_tag]}, # Use alias to set d field - ) - events_to_delete = await get_events( - self.relay_id, nostr_filter, False - ) - ids_to_delete.extend( - [ - e.id - for e in events_to_delete - if not e.is_delete_event - ] - ) - else: - logger.warning( - f"Deletion request pubkey mismatch: {addr_pubkey} != {event.pubkey}" - ) - except ValueError: - logger.warning(f"Invalid kind in address: {addr}") - else: - logger.warning( - f"Invalid address format (expected kind:pubkey:d-tag): {addr}" - ) - - # Only mark events as deleted if we found specific IDs if ids_to_delete: await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete)) + @staticmethod + def _address_filter(address: str, author: str) -> NostrFilter | None: + # `kind:pubkey:d`; the `d` value may itself contain ':'. + parts = address.split(":", 2) + if len(parts) != 3 or not parts[0].isdigit(): + return None + kind, pubkey, d_tag = int(parts[0]), parts[1], parts[2] + if pubkey != author: + return None + # NIP-01: replaceable are 0, 3 and 10000-19999; addressable 30000-39999. + replaceable = kind in (0, 3) or 10000 <= kind < 20000 + addressable = 30000 <= kind < 40000 + if not (replaceable or addressable): + return None + if d_tag: + # `d` is a Pydantic alias field; it must be set through the alias. + return NostrFilter( + authors=[author], kinds=[kind], **{"#d": [d_tag]} # type: ignore[arg-type] + ) + return NostrFilter(authors=[author], kinds=[kind]) + async def _handle_request( self, subscription_id: str, nostr_filter: NostrFilter ) -> list: diff --git a/relay/event.py b/relay/event.py index b6343e0..dc23234 100644 --- a/relay/event.py +++ b/relay/event.py @@ -120,5 +120,8 @@ class NostrEvent(BaseModel): def has_tag_value(self, tag_name: str, tag_value: str) -> bool: return tag_value in self.tag_values(tag_name) + def has_tag_value_other_than(self, tag_name: str, value: str) -> bool: + return any(t[0] == tag_name and t[1] != value for t in self.tags if len(t) > 1) + def is_direct_message_for_pubkey(self, pubkey: str) -> bool: return self.is_direct_message and self.has_tag_value("p", pubkey) diff --git a/relay/filter.py b/relay/filter.py index a01aaf6..dd12352 100644 --- a/relay/filter.py +++ b/relay/filter.py @@ -64,6 +64,10 @@ class NostrFilter(BaseModel): and (not self.until) ) + @property + def is_addressable(self) -> bool: + return all(30000 <= k < 40000 for k in self.kinds) and len(self.kinds) > 0 + def enforce_limit(self, limit: int): if not self.limit or self.limit > limit: self.limit = limit diff --git a/tests/test_nip09.py b/tests/test_nip09.py new file mode 100644 index 0000000..04f4d57 --- /dev/null +++ b/tests/test_nip09.py @@ -0,0 +1,137 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ..crud import create_event, get_events +from ..relay.client_connection import NostrClientConnection +from ..relay.event import NostrEvent +from ..relay.filter import NostrFilter +from ..relay.relay import RelaySpec + +ALICE = "a" * 64 +BOB = "b" * 64 +SIG = "0" * 128 + + +def _connection(relay_id: str) -> NostrClientConnection: + conn = NostrClientConnection(relay_id=relay_id, websocket=MagicMock()) + conn.get_client_config = lambda: RelaySpec() + conn._send_msg = AsyncMock() # type: ignore[method-assign] + return conn + + +def _event(relay_id, id_, pubkey, kind, created_at, tags=None) -> NostrEvent: + return NostrEvent( + id=id_.rjust(64, "0"), + relay_id=relay_id, + publisher=pubkey, + pubkey=pubkey, + created_at=created_at, + kind=kind, + tags=tags or [], + content="", + sig=SIG, + ) + + +async def _remaining(relay_id: str) -> set[str]: + events = await get_events(relay_id, NostrFilter(kinds=[1, 3, 30023]), False) + return {e.id.lstrip("0") or "0" for e in events} + + +@pytest.mark.asyncio +async def test_a_tag_deletes_only_the_addressed_event(): + relay_id = "nip09_a_tag" + await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "one"]])) + await create_event(_event(relay_id, "2", ALICE, 30023, 100, [["d", "two"]])) + await create_event(_event(relay_id, "3", BOB, 30023, 100, [["d", "one"]])) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:one"]]) + ) + + assert await _remaining(relay_id) == {"2", "3"} + + +@pytest.mark.asyncio +async def test_a_tag_keeps_versions_newer_than_the_request(): + relay_id = "nip09_until" + await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "one"]])) + await create_event(_event(relay_id, "2", ALICE, 30023, 300, [["d", "one"]])) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:one"]]) + ) + + assert await _remaining(relay_id) == {"2"} + + +@pytest.mark.asyncio +async def test_a_tag_for_replaceable_kind_has_empty_d(): + relay_id = "nip09_replaceable" + await create_event(_event(relay_id, "1", ALICE, 3, 100)) + await create_event(_event(relay_id, "2", ALICE, 1, 100)) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"3:{ALICE}:"]]) + ) + + assert await _remaining(relay_id) == {"2"} + + +@pytest.mark.asyncio +async def test_a_tag_for_non_addressable_kind_is_ignored(): + relay_id = "nip09_kind1" + await create_event(_event(relay_id, "1", ALICE, 1, 100)) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"1:{ALICE}:"]]) + ) + + assert await _remaining(relay_id) == {"1"} + + +@pytest.mark.asyncio +async def test_a_tag_d_value_may_contain_colons(): + relay_id = "nip09_colon" + await create_event(_event(relay_id, "1", ALICE, 30023, 100, [["d", "a:b:c"]])) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:a:b:c"]]) + ) + + assert await _remaining(relay_id) == set() + + +@pytest.mark.asyncio +async def test_e_tag_deletes_only_own_events(): + relay_id = "nip09_e_tag" + await create_event(_event(relay_id, "1", ALICE, 1, 100)) + await create_event(_event(relay_id, "2", BOB, 1, 100)) + + await _connection(relay_id)._handle_delete_event( + _event( + relay_id, + "9", + ALICE, + 5, + 200, + [["e", "1".rjust(64, "0")], ["e", "2".rjust(64, "0")]], + ) + ) + + assert await _remaining(relay_id) == {"2"} + + +@pytest.mark.asyncio +async def test_a_tag_with_empty_d_on_addressable_kind_is_specific(): + relay_id = "nip09_empty_d" + await create_event(_event(relay_id, "1", ALICE, 30023, 100)) + await create_event(_event(relay_id, "2", ALICE, 30023, 100, [["d", ""]])) + await create_event(_event(relay_id, "3", ALICE, 30023, 100, [["d", "one"]])) + + await _connection(relay_id)._handle_delete_event( + _event(relay_id, "9", ALICE, 5, 200, [["a", f"30023:{ALICE}:"]]) + ) + + assert await _remaining(relay_id) == {"3"} -- 2.55.0 From 2f965e0023cf100b7820f0717207ca7f4969c06b Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:55:13 +0200 Subject: [PATCH 4/6] fix: send only NIP-01 fields in EVENT responses nostr_dict() returned every model field except relay_id and publisher, so the size column added for storage accounting leaked into every EVENT sent to clients. Emit the seven NIP-01 fields explicitly instead. Caught by the client-flow test ported from upstream. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt --- relay/event.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/relay/event.py b/relay/event.py index dc23234..ed9054f 100644 --- a/relay/event.py +++ b/relay/event.py @@ -26,10 +26,17 @@ class NostrEvent(BaseModel): size: int = 0 def nostr_dict(self) -> dict: - _nostr_dict = dict(self) - _nostr_dict.pop("relay_id") - _nostr_dict.pop("publisher") - return _nostr_dict + # Only the NIP-01 fields go on the wire; storage columns such as + # relay_id, publisher and size must not leak into EVENT responses. + return { + "id": self.id, + "pubkey": self.pubkey, + "created_at": self.created_at, + "kind": self.kind, + "tags": self.tags, + "content": self.content, + "sig": self.sig, + } def serialize(self) -> list: return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] -- 2.55.0 From 42d3c76e1c52250a4a5b0f6e36236299de51c820 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:55:13 +0200 Subject: [PATCH 5/6] chore: wrap an over-long log line Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt --- relay/client_connection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index 1480e2a..f6eb742 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -113,7 +113,8 @@ class NostrClientConnection: await self.broadcast_event(self, e) else: logger.warning( - f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}" + "[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available " + f"for event {e.id}" ) async def _handle_message(self, data: list) -> list: -- 2.55.0 From cbcdce8c4188361ed2b2efb87c03ac465211f854 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:54:09 +0200 Subject: [PATCH 6/6] chore: bump version to 1.1.0-aio.4 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index 6831512..036456c 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "name": "Nostr Relay", - "version": "1.1.0-aio.3", + "version": "1.1.0-aio.4", "short_description": "One click launch your own relay!", "tile": "/nostrrelay/static/image/nostrrelay.png", "min_lnbits_version": "1.4.0", -- 2.55.0