Port upstream tests and rebuilt NIP-09 handling #7

Merged
padreug merged 6 commits from test/port-upstream-tests into main 2026-09-13 16:56:18 +00:00
5 changed files with 191 additions and 52 deletions
Showing only changes of commit fd19acff97 - Show all commits

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
(cherry picked from commit e695f9c66881d3d520d40f0cd3e9c95d9435938c)
Patrick Mulligan 2026-09-13 17:28:11 +02:00 • committed by Padreug

View file

@ -24,8 +24,8 @@
- [x] **NIP-04**: Encrypted Direct Message - [x] **NIP-04**: Encrypted Direct Message
- if `AUTH` enabled: send only to the intended target - if `AUTH` enabled: send only to the intended target
- [x] **NIP-09**: Event Deletion - [x] **NIP-09**: Event Deletion
- [x] 'e' tags: Delete regular events by event ID - [x] `e` tags: delete events by id
- [x] 'a' tags: Delete addressable events by address (kind:pubkey:d-identifier) - [x] `a` tags: delete replaceable / addressable events by `kind:pubkey:d`
- [x] **NIP-11**: Relay Information Document - [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/) - > **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 - [ ] **NIP-12**: Generic Tag Queries

View file

@ -229,66 +229,61 @@ class NostrClientConnection:
await self.websocket.send_text(json.dumps(data)) await self.websocket.send_text(json.dumps(data))
async def _handle_delete_event(self, event: NostrEvent): 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"] 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: if event_ids:
nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids) nostr_filter = NostrFilter(authors=[event.pubkey], ids=event_ids)
events_to_delete = await get_events(self.relay_id, nostr_filter, False) events_to_delete = await get_events(self.relay_id, nostr_filter, False)
ids_to_delete.extend( ids_to_delete += [e.id for e in events_to_delete if not e.is_delete_event]
[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: if ids_to_delete:
await mark_events_deleted(self.relay_id, NostrFilter(ids=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( async def _handle_request(
self, subscription_id: str, nostr_filter: NostrFilter self, subscription_id: str, nostr_filter: NostrFilter
) -> list: ) -> list:

View file

@ -120,5 +120,8 @@ class NostrEvent(BaseModel):
def has_tag_value(self, tag_name: str, tag_value: str) -> bool: def has_tag_value(self, tag_name: str, tag_value: str) -> bool:
return tag_value in self.tag_values(tag_name) 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: def is_direct_message_for_pubkey(self, pubkey: str) -> bool:
return self.is_direct_message and self.has_tag_value("p", pubkey) return self.is_direct_message and self.has_tag_value("p", pubkey)

View file

@ -64,6 +64,10 @@ class NostrFilter(BaseModel):
and (not self.until) 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): def enforce_limit(self, limit: int):
if not self.limit or self.limit > limit: if not self.limit or self.limit > limit:
self.limit = limit self.limit = limit

137
tests/test_nip09.py Normal file
View file

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