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
This commit is contained in:
PatMulligan 2025-09-10 15:40:40 +02:00 committed by GitHub
parent 687d7b89c1
commit 22df5868de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 51 additions and 7 deletions

View file

@ -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

15
crud.py
View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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})")