From 22df5868dedda0f0b522e2bc6a872af9dbe4146e Mon Sep 17 00:00:00 2001 From: PatMulligan <43773168+PatMulligan@users.noreply.github.com> Date: Wed, 10 Sep 2025 15:40:40 +0200 Subject: [PATCH] FEAT: Implement NIP-01 Addressable Events Support (#33) * Implement NIP-16 parameterized replaceable events Add support for parameterized replaceable events (kinds 30000-39999) to properly handle Nostr marketplace product and stall updates according to NIP-16 specification. Changes: - Add is_parameterized_replaceable_event property to NostrEvent - Implement automatic deletion of previous versions when new parameterized replaceable event is received - Add 'd' tag filtering support to NostrFilter for parameterized replacement logic - Update SQL query generation to handle 'd' tag joins Fixes issue where product updates would create duplicate entries instead of replacing previous versions, ensuring only the latest version remains visible. * Refactor event handling for addressable events Renamed the property is_parameterized_replaceable_event to is_addressable_event in NostrEvent to align with NIP-01 specifications (previously NIP-16). Updated the client_connection.py to utilize the new property for extracting 'd' tag values for addressable replacement, ensuring proper event handling in the relay system. * Refactor tag filtering logic in NostrFilter Updated the tag filtering mechanism to ensure that the filter only fails if the specified tags ('e' and 'p') are not found. This change improves clarity and maintains functionality by allowing for more precise control over event filtering. * update readme * Fix addressable event deletion and SQL schema issues - Fix Pydantic field alias usage for d tag filtering (use #d instead of d) - Remove nostrrelay schema prefixes from SQL table references - Implement subquery approach for DELETE operations with JOINs - Resolve SQLite DELETE syntax incompatibility with JOIN statements - Ensure NIP-33 compliance: only delete events with matching d tag values --- README.md | 8 ++++++-- crud.py | 15 +++++++++++++-- relay/client_connection.py | 13 +++++++++++++ relay/event.py | 4 ++++ relay/filter.py | 18 +++++++++++++++--- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7955288..ecc18f8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ ## Supported NIPs - [x] **NIP-01**: Basic protocol flow + - [x] Regular Events + - [x] Replaceable Events (kinds 10000-19999) + - [x] Ephemeral Events (kinds 20000-29999) + - [x] Addressable Events (kinds 30000-39999) - [x] **NIP-02**: Contact List and Petnames - `kind: 3`: delete past contact lists as soon as the relay receives a new one - [x] **NIP-04**: Encrypted Direct Message @@ -36,8 +40,8 @@ - not planned - [x] **NIP-28** Public Chat - `kind: 41`: handled similar to `kind 0` metadata events -- [ ] **NIP-33**: Parameterized Replaceable Events - - todo +- [x] **NIP-33**: Addressable Events (moved to NIP-01) + - ✅ Implemented as part of NIP-01 addressable events - [ ] **NIP-40**: Expiration Timestamp - todo - [x] **NIP-42**: Authentication of clients to relays diff --git a/crud.py b/crud.py index a084c3a..82fb056 100644 --- a/crud.py +++ b/crud.py @@ -193,9 +193,20 @@ async def mark_events_deleted(relay_id: str, nostr_filter: NostrFilter): async def delete_events(relay_id: str, nostr_filter: NostrFilter): if nostr_filter.is_empty(): return None - _, where, values = nostr_filter.to_sql_components(relay_id) + inner_joins, where, values = nostr_filter.to_sql_components(relay_id) - query = f"DELETE from nostrrelay.events WHERE {' AND '.join(where)}" + if inner_joins: + # Use subquery for DELETE operations with JOINs + subquery = f""" + SELECT nostrrelay.events.id FROM nostrrelay.events + {" ".join(inner_joins)} + WHERE {" AND ".join(where)} + """ + query = f"DELETE FROM nostrrelay.events WHERE id IN ({subquery})" + else: + # Simple DELETE without JOINs + query = f"DELETE FROM events WHERE {' AND '.join(where)}" + await db.execute(query, values) # todo: delete tags diff --git a/relay/client_connection.py b/relay/client_connection.py index dbe459e..7c0fdde 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -166,6 +166,19 @@ class NostrClientConnection: self.relay_id, NostrFilter(kinds=[e.kind], authors=[e.pubkey], until=e.created_at), ) + if e.is_addressable_event: + # Extract 'd' tag value for addressable replacement (NIP-01) + d_tag_value = next((t[1] for t in e.tags if t[0] == "d"), None) + + if d_tag_value: + deletion_filter = NostrFilter( + kinds=[e.kind], + authors=[e.pubkey], + **{"#d": [d_tag_value]}, + until=e.created_at + ) + + await delete_events(self.relay_id, deletion_filter) if not e.is_ephemeral_event: await create_event(e) await self._broadcast_event(e) diff --git a/relay/event.py b/relay/event.py index 5e9e923..44d34ae 100644 --- a/relay/event.py +++ b/relay/event.py @@ -71,6 +71,10 @@ class NostrEvent(BaseModel): def is_ephemeral_event(self) -> bool: return self.kind >= 20000 and self.kind < 30000 + @property + def is_addressable_event(self) -> bool: + return self.kind >= 30000 and self.kind < 40000 + def check_signature(self): event_id = self.event_id if self.id != event_id: diff --git a/relay/filter.py b/relay/filter.py index 1160cc9..835b893 100644 --- a/relay/filter.py +++ b/relay/filter.py @@ -8,6 +8,7 @@ from .event import NostrEvent class NostrFilter(BaseModel): e: list[str] = Field(default=[], alias="#e") p: list[str] = Field(default=[], alias="#p") + d: list[str] = Field(default=[], alias="#d") ids: list[str] = [] authors: list[str] = [] kinds: list[int] = [] @@ -30,9 +31,12 @@ class NostrFilter(BaseModel): if self.until and self.until > 0 and e.created_at > self.until: return False - found_e_tag = self.tag_in_list(e.tags, "e") - found_p_tag = self.tag_in_list(e.tags, "p") - if not found_e_tag or not found_p_tag: + # Check tag filters - only fail if filter is specified and no match found + if not self.tag_in_list(e.tags, "e"): + return False + if not self.tag_in_list(e.tags, "p"): + return False + if not self.tag_in_list(e.tags, "d"): return False return True @@ -87,6 +91,14 @@ class NostrFilter(BaseModel): ) where.append(f" p_tags.value in ({p_s}) AND p_tags.name = 'p'") + if len(self.d): + d_s = ",".join([f"'{d}'" for d in self.d]) + d_join = "INNER JOIN nostrrelay.event_tags d_tags ON nostrrelay.events.id = d_tags.event_id" + d_where = f" d_tags.value in ({d_s}) AND d_tags.name = 'd'" + + inner_joins.append(d_join) + where.append(d_where) + if len(self.ids) != 0: ids = ",".join([f"'{_id}'" for _id in self.ids]) where.append(f"id IN ({ids})")