From 8bfd79254853904fe797c49d3bef56615dae4d2c Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sun, 16 Nov 2025 22:18:02 +0100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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: