diff --git a/README.md b/README.md index d5f7ade..ecc18f8 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,6 @@ - [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 @@ -35,12 +33,6 @@ - [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 @@ -54,12 +46,8 @@ - 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 b9c9708..77a25b9 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_private_event_for_other(event): + if self._is_direct_message_for_other(event): return False for nostr_filter in self.filters: @@ -77,20 +77,15 @@ 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_private_event_for_other(self, event: NostrEvent) -> bool: + def _is_direct_message_for_other(self, event: NostrEvent) -> bool: """ - 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. + 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. """ - if not event.is_private_message: + if not event.is_direct_message: return False if not self.config.event_requires_auth(event.kind): return False @@ -103,10 +98,6 @@ 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: @@ -122,8 +113,6 @@ 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: @@ -131,8 +120,6 @@ 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( @@ -221,65 +208,12 @@ class NostrClientConnection: await self.websocket.send_text(json.dumps(data)) async def _handle_delete_event(self, event: NostrEvent): - # 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: - # 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: - await mark_events_deleted(self.relay_id, NostrFilter(ids=ids_to_delete)) + # 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)) async def _handle_request( self, subscription_id: str, nostr_filter: NostrFilter @@ -306,7 +240,8 @@ class NostrClientConnection: return [["NOTICE", f"This is a paid relay: '{self.relay_id}'"]] nostr_filter.subscription_id = subscription_id - if not self._can_add_filter(): + self._remove_filter(subscription_id) + if self._can_add_filter(): max_filters = self.config.max_client_filters return [ [ @@ -318,7 +253,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_private_event_for_other(e)] + events = [e for e in events if not self._is_direct_message_for_other(e)] serialized_events = [ event.serialize_response(subscription_id) for event in events ] @@ -337,8 +272,8 @@ class NostrClientConnection: def _can_add_filter(self) -> bool: return ( - self.config.max_client_filters == 0 - or len(self.filters) < self.config.max_client_filters + self.config.max_client_filters != 0 + and len(self.filters) >= self.config.max_client_filters ) def _auth_challenge_expired(self): diff --git a/relay/event.py b/relay/event.py index b6343e0..7154ece 100644 --- a/relay/event.py +++ b/relay/event.py @@ -23,7 +23,6 @@ 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) @@ -64,21 +63,6 @@ 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 29e0121..80677c7 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, 17, 20, 22, 28, 42, 44, 59], + "supported_nips": [1, 2, 4, 9, 11, 15, 16, 20, 22, 28, 42], "software": "LNbits", "version": "", } diff --git a/tests/test_nip17.py b/tests/test_nip17.py deleted file mode 100644 index c53070b..0000000 --- a/tests/test_nip17.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -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