From 8bfd79254853904fe797c49d3bef56615dae4d2c Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sun, 16 Nov 2025 22:18:02 +0100 Subject: [PATCH 1/7] Add NIP-09 support for parameterized replaceable events (NIP-33) Extended NIP-09 deletion event handling to support both regular events and parameterized replaceable events (NIP-33). **Previous behavior:** - Only handled 'e' tags (regular event IDs) - Did not support 'a' tags for addressable/replaceable events **New behavior:** - Handles both 'e' tags (event IDs) and 'a' tags (event addresses) - Parses 'a' tag format: kind:pubkey:d-identifier - Validates deletion author matches event address pubkey (NIP-09 requirement) - Creates appropriate filters for each deletion type **Implementation:** - Added parsing for 'a' tag event addresses - Extract kind, pubkey, and d-tag from address format - Build NostrFilter with authors, kinds, and d-tag parameters - Collect all event IDs to delete from both 'e' and 'a' tags - Mark matching events as deleted in single operation This enables proper deletion of parameterized replaceable events like calendar events (kind 31922-31924), long-form content (kind 30023), and other addressable event kinds. Implements NIP-09: https://github.com/nostr-protocol/nips/blob/master/09.md Supports NIP-33: https://github.com/nostr-protocol/nips/blob/master/33.md --- relay/client_connection.py | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index 77a25b9..ed9a84b 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -208,12 +208,46 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - # NIP 09 - nostr_filter = NostrFilter(authors=[event.pubkey]) - nostr_filter.ids = [t[1] for t in event.tags if t[0] == "e"] - events_to_delete = await get_events(self.relay_id, nostr_filter, False) - ids = [e.id for e in events_to_delete if not e.is_delete_event] - await mark_events_deleted(self.relay_id, NostrFilter(ids=ids)) + # NIP 09 - Handle both regular events (e tags) and parameterized replaceable events (a tags) + + # 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]) + + # 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: + nostr_filter = NostrFilter( + authors=[addr_pubkey], + kinds=[kind], + d=[d_tag] + ) + 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]) + except ValueError: + logger.warning(f"Invalid kind in address: {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)) async def _handle_request( self, subscription_id: str, nostr_filter: NostrFilter From dcc3204735f74cf2df4bf604a9fcacdbb5fd1050 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sun, 16 Nov 2025 22:47:16 +0100 Subject: [PATCH 2/7] Fix NIP-09 deletion for parameterized replaceable events (NIP-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed bug where deleting a parameterized replaceable event (e.g., kind 31922) using an 'a' tag would incorrectly delete ALL events of that kind instead of just the specific event with the matching d-tag. **Root Cause:** NostrFilter's 'd' field uses a Pydantic Field alias "#d". When creating a filter with `NostrFilter(d=[value])`, Pydantic ignores it because the parameter name doesn't match the alias. **Fix:** Changed filter creation to use the alias: ```python NostrFilter(authors=[...], kinds=[...], **{"#d": [d_tag]}) ``` **Testing:** - Created two tasks with different d-tags - Deleted only one task - Verified only the specified task was marked as deleted in the database - Confirmed the other task remained unaffected This ensures proper NIP-09 deletion behavior for NIP-33 parameterized replaceable events using 'a' tag format (kind:pubkey:d-identifier). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- relay/client_connection.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index ed9a84b..a64bccc 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -235,15 +235,20 @@ class NostrClientConnection: 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] + **{"#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: From 85478642545e16de92333212922dc2a470042a8b Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sun, 16 Nov 2025 23:43:42 +0100 Subject: [PATCH 3/7] docs: Update README with complete NIP-09 deletion support Updated NIP-09 section to document full implementation including: - 'e' tags for deleting regular events by event ID - 'a' tags for deleting addressable events by address format (kind:pubkey:d-identifier) This reflects the implementation added in commits 3ba3318 and 538fe42 which brought the relay into full NIP-09 compliance. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ecc18f8..21ac93f 100644 --- a/README.md +++ b/README.md @@ -24,6 +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] **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 From 5e95b309fe658ca56003bb60595643dbef5c4f10 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sun, 16 Nov 2025 23:45:21 +0100 Subject: [PATCH 4/7] make format --- relay/client_connection.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index a64bccc..695b369 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -222,7 +222,9 @@ class NostrClientConnection: 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.extend( + [e.id for e in events_to_delete if not e.is_delete_event] + ) # Handle parameterized replaceable event deletions (a tags) if event_addresses: @@ -239,16 +241,28 @@ class NostrClientConnection: nostr_filter = NostrFilter( authors=[addr_pubkey], kinds=[kind], - **{"#d": [d_tag]} # Use alias to set d field + **{"#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 + ] ) - 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}") + 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}") + 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: From 8d6f482de024f93f477a142cbc5a7f9c487eb84a Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 6 Jan 2026 23:05:22 +0100 Subject: [PATCH 5/7] Fix critical filter logic bugs preventing event propagation in Nostr relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix inverted logic in _can_add_filter() method that was preventing new subscription filters from being added - Fix REQ message handling to properly clear existing filters before adding new ones - Fix inverted condition check when validating filter addition capacity - Add debug logging to track filter matching and broadcast failures These bugs were causing customer order events (NIP-15) to be received by the relay but not forwarded to nostrclient/nostrmarket, requiring server restarts or manual refresh to process orders. The fix ensures proper event propagation: Customer → Relay → nostrclient → nostrmarket → Invoice. Root cause: The _can_add_filter() method returned true when filters >= max instead of when filters < max, and the validation check used the wrong conditional, effectively blocking all new filter subscriptions after initial connection. --- relay/client_connection.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/relay/client_connection.py b/relay/client_connection.py index 695b369..e7ebe34 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -77,6 +77,10 @@ class NostrClientConnection: resp = event.serialize_response(nostr_filter.subscription_id) await self._send_msg(resp) return True + else: + logger.info( + f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}" + ) return False def _is_direct_message_for_other(self, event: NostrEvent) -> bool: @@ -98,6 +102,10 @@ class NostrClientConnection: async def _broadcast_event(self, e: NostrEvent): if self.broadcast_event: await self.broadcast_event(self, e) + else: + logger.warning( + f"[NOSTRRELAY CLIENT] ❌ No broadcast_event callback available for event {e.id}" + ) async def _handle_message(self, data: list) -> list: if len(data) < 2: @@ -120,6 +128,8 @@ class NostrClientConnection: return [] subscription_id = data[1] # Handle multiple filters in REQ message + # First remove existing filters for this subscription_id + self._remove_filter(subscription_id) responses = [] for filter_data in data[2:]: response = await self._handle_request( @@ -293,8 +303,7 @@ class NostrClientConnection: return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]] nostr_filter.subscription_id = subscription_id - self._remove_filter(subscription_id) - if self._can_add_filter(): + if not self._can_add_filter(): max_filters = self.config.max_client_filters return [ [ @@ -325,8 +334,8 @@ class NostrClientConnection: def _can_add_filter(self) -> bool: return ( - self.config.max_client_filters != 0 - and len(self.filters) >= self.config.max_client_filters + self.config.max_client_filters == 0 + or len(self.filters) < self.config.max_client_filters ) def _auth_challenge_expired(self): From b74af2628ed835fad514347e9c11dc7dfe090d5a Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 6 Jan 2026 23:53:37 +0100 Subject: [PATCH 6/7] fix(nostrrelay): populate size field for event storage accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - relay/event.py: Add `size: int = 0` field to NostrEvent model - relay/client_connection.py: Set `event.size = event.size_bytes` when creating events from WebSocket messages The size field has existed in the database schema since migration m001 but was never populated, causing: - Incorrect storage accounting (always 0) - Broken storage quota enforcement - Failed event pruning when storage limits reached The size field is internal relay metadata and is excluded from the nostr_dict() output, maintaining NIP-01 compliance. The size_bytes property calculates the actual byte size of the event's JSON representation. Fixes: Database constraint violation when inserting events without the required size column value. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- relay/client_connection.py | 2 ++ relay/event.py | 1 + 2 files changed, 3 insertions(+) diff --git a/relay/client_connection.py b/relay/client_connection.py index e7ebe34..8b5fdfd 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -121,6 +121,8 @@ class NostrClientConnection: } event = NostrEvent(**event_dict) + # Set the size field from the size_bytes property + event.size = event.size_bytes await self._handle_event(event) return [] if message_type == NostrEventType.REQ: diff --git a/relay/event.py b/relay/event.py index 7154ece..8cee1b9 100644 --- a/relay/event.py +++ b/relay/event.py @@ -23,6 +23,7 @@ class NostrEvent(BaseModel): tags: list[list[str]] = Field(default=[], no_database=True) content: str = "" sig: str + size: int = 0 def nostr_dict(self) -> dict: _nostr_dict = dict(self) From 4811fcf352c16cda06550fb93f1271e5a0af0dab Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 13:59:14 +0200 Subject: [PATCH 7/7] feat(nip17): support gift-wrapped private direct messages Generalize the AUTH-gated, recipient-only delivery rule from NIP-04 to also cover NIP-17 kind 1059 gift wraps. When the relay is configured to require AUTH for kind 1059, only the AUTH'd recipient named in the event's `p` tag receives it; otherwise gift wraps broadcast like any regular event. - relay/event.py: add `is_seal`, `is_gift_wrap`, `is_private_message` helpers (kinds 13, 1059) - relay/client_connection.py: rename `_is_direct_message_for_other` -> `_is_private_event_for_other`; key off `is_private_message` so the same gating applies to kinds 4 and 1059 - relay/relay.py: advertise NIPs 17, 44, 59 in NIP-11 supported_nips - README: document NIP-17/44/59 transport-level support - tests/test_nip17.py: unit tests for kind classification, AUTH-gated 1059 delivery (recipient vs non-recipient vs unauthenticated), and regression coverage for kind 4 gating NIP-44 (encryption) and NIP-59 (wrap/seal) are client-side concerns; the relay treats payloads as opaque ciphertext and stores kind 1059 like any regular event. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++ relay/client_connection.py | 15 +++-- relay/event.py | 15 +++++ relay/relay.py | 2 +- tests/test_nip17.py | 132 +++++++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 tests/test_nip17.py diff --git a/README.md b/README.md index 21ac93f..d5f7ade 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ - [x] Regular Events - [x] Replaceable Events - [x] Ephemeral Events +- [x] **NIP-17**: Private Direct Messages + - `kind: 1059` gift wraps stored and broadcast like regular events + - if `AUTH` enabled for `kind: 1059`: deliver only to the recipient + named in the `p` tag (same gating as NIP-04) + - encryption (NIP-44) and wrapping (NIP-59) are client-side concerns; + the relay handles transport only - [x] **NIP-20**: Command Results - todo: use correct prefixes - [x] **NIP-22**: Event created_at Limits @@ -48,8 +54,12 @@ - todo - [x] **NIP-42**: Authentication of clients to relays - todo: use correct prefix +- [x] **NIP-44**: Encrypted Payloads (Versioned) + - relay treats payloads as opaque; encryption is client-side - [ ] **NIP-50**: Search Capability - todo +- [x] **NIP-59**: Gift Wrap + - `kind: 13` (seal) and `kind: 1059` (gift wrap) accepted; unwrapping is client-side ## Create Relay diff --git a/relay/client_connection.py b/relay/client_connection.py index 8b5fdfd..b9c9708 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -69,7 +69,7 @@ class NostrClientConnection: self.event_validator.get_client_config = get_client_config async def notify_event(self, event: NostrEvent) -> bool: - if self._is_direct_message_for_other(event): + if self._is_private_event_for_other(event): return False for nostr_filter in self.filters: @@ -83,13 +83,14 @@ class NostrClientConnection: ) return False - def _is_direct_message_for_other(self, event: NostrEvent) -> bool: + def _is_private_event_for_other(self, event: NostrEvent) -> bool: """ - Direct messages are not inteded to be boradcast (even if encrypted). - If the server requires AUTH for kind '4' then direct message will be - sent only to the intended client. + p-tagged events that carry a single intended recipient (NIP-04 kind 4 + direct messages and NIP-17 kind 1059 gift wraps) should not be + broadcast to arbitrary subscribers when the relay enforces AUTH for + that kind. Deliver only to the AUTH'd recipient named in a `p` tag. """ - if not event.is_direct_message: + if not event.is_private_message: return False if not self.config.event_requires_auth(event.kind): return False @@ -317,7 +318,7 @@ class NostrClientConnection: nostr_filter.enforce_limit(self.config.limit_per_filter) self.filters.append(nostr_filter) events = await get_events(self.relay_id, nostr_filter) - events = [e for e in events if not self._is_direct_message_for_other(e)] + events = [e for e in events if not self._is_private_event_for_other(e)] serialized_events = [ event.serialize_response(subscription_id) for event in events ] diff --git a/relay/event.py b/relay/event.py index 8cee1b9..b6343e0 100644 --- a/relay/event.py +++ b/relay/event.py @@ -64,6 +64,21 @@ class NostrEvent(BaseModel): def is_delete_event(self) -> bool: return self.kind == 5 + @property + def is_seal(self) -> bool: + return self.kind == 13 + + @property + def is_gift_wrap(self) -> bool: + return self.kind == 1059 + + @property + def is_private_message(self) -> bool: + # Kinds whose payload addresses a single recipient via a `p` tag and is + # not meant to be broadcast to other subscribers when AUTH is enforced. + # NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). + return self.is_direct_message or self.is_gift_wrap + @property def is_regular_event(self) -> bool: return self.kind >= 1000 and self.kind < 10000 diff --git a/relay/relay.py b/relay/relay.py index 80677c7..29e0121 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -116,7 +116,7 @@ class NostrRelay(BaseModel): ) -> dict: return { "contact": "https://t.me/lnbits", - "supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42], + "supported_nips": [1, 2, 4, 9, 11, 15, 16, 17, 20, 22, 28, 42, 44, 59], "software": "LNbits", "version": "", } diff --git a/tests/test_nip17.py b/tests/test_nip17.py new file mode 100644 index 0000000..c53070b --- /dev/null +++ b/tests/test_nip17.py @@ -0,0 +1,132 @@ +""" +Unit tests for NIP-17 (Private Direct Messages) handling. + +Covers: + - kind 1059 (gift wrap) and kind 13 (seal) classification on NostrEvent + - the AUTH-gated private-recipient delivery rule in NostrClientConnection +""" + +from unittest.mock import MagicMock + +import pytest + +from ..relay.client_connection import NostrClientConnection +from ..relay.event import NostrEvent +from ..relay.relay import RelaySpec + +RELAY_ID = "relay_nip17" +RECIPIENT = "1111111111111111111111111111111111111111111111111111111111111111" +OTHER = "2222222222222222222222222222222222222222222222222222222222222222" +EPHEMERAL = "3333333333333333333333333333333333333333333333333333333333333333" +SIG = "0" * 128 + + +def _gift_wrap_for(recipient: str) -> NostrEvent: + """Build a kind 1059 event addressed to recipient. Skips signature validity.""" + return NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=EPHEMERAL, + pubkey=EPHEMERAL, + created_at=0, + kind=1059, + tags=[["p", recipient]], + content="ciphertext", + sig=SIG, + ) + + +def test_kind_classification_helpers(): + seal = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=OTHER, + pubkey=OTHER, + created_at=0, + kind=13, + tags=[], + content="", + sig=SIG, + ) + wrap = _gift_wrap_for(RECIPIENT) + + assert seal.is_seal + assert not seal.is_gift_wrap + assert not seal.is_private_message # seals carry no recipient metadata + + assert wrap.is_gift_wrap + assert not wrap.is_seal + assert wrap.is_private_message + assert not wrap.is_ephemeral_event # nostrmarket relies on storage + + +def _make_connection(relay_spec: RelaySpec) -> NostrClientConnection: + conn = NostrClientConnection(relay_id=RELAY_ID, websocket=MagicMock()) + conn.get_client_config = lambda: relay_spec + return conn + + +@pytest.mark.parametrize( + "force_auth,auth_pubkey,event_recipient,expected_filtered", + [ + # AUTH not required for 1059 -> never filtered (matches NIP-04 default) + (False, None, RECIPIENT, False), + (False, RECIPIENT, RECIPIENT, False), + (False, OTHER, RECIPIENT, False), + # AUTH required for 1059 -> only the recipient gets it + (True, None, RECIPIENT, True), + (True, RECIPIENT, RECIPIENT, False), + (True, OTHER, RECIPIENT, True), + ], +) +def test_gift_wrap_auth_gated_delivery( + force_auth, auth_pubkey, event_recipient, expected_filtered +): + spec = RelaySpec(forcedAuthEvents=[1059] if force_auth else []) + conn = _make_connection(spec) + conn.auth_pubkey = auth_pubkey + + wrap = _gift_wrap_for(event_recipient) + assert conn._is_private_event_for_other(wrap) is expected_filtered + + +def test_kind_4_dm_still_gated_under_auth(): + """Regression: the NIP-04 gating behavior must remain identical.""" + spec = RelaySpec(forcedAuthEvents=[4]) + conn = _make_connection(spec) + conn.auth_pubkey = OTHER + + dm = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=RECIPIENT, + pubkey=RECIPIENT, + created_at=0, + kind=4, + tags=[["p", RECIPIENT]], + content="ciphertext", + sig=SIG, + ) + assert conn._is_private_event_for_other(dm) is True + + conn.auth_pubkey = RECIPIENT + assert conn._is_private_event_for_other(dm) is False + + +def test_non_private_kinds_never_filtered(): + spec = RelaySpec(forcedAuthEvents=[1059, 4]) + conn = _make_connection(spec) + conn.auth_pubkey = OTHER + + note = NostrEvent( + id="0" * 64, + relay_id=RELAY_ID, + publisher=RECIPIENT, + pubkey=RECIPIENT, + created_at=0, + kind=1, + tags=[["p", RECIPIENT]], + content="hello", + sig=SIG, + ) + assert conn._is_private_event_for_other(note) is False