diff --git a/config.json b/config.json index a837ae0..6831512 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "name": "Nostr Relay", - "version": "1.1.0", + "version": "1.1.0-aio.3", "short_description": "One click launch your own relay!", "tile": "/nostrrelay/static/image/nostrrelay.png", "min_lnbits_version": "1.4.0", diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md new file mode 100644 index 0000000..d084044 --- /dev/null +++ b/docs/upstream-candidates.md @@ -0,0 +1,8 @@ +# Upstream candidates + +Fork changes that `lnbits/nostrrelay` might want, kept in upstream's shape +so a future rebase merges cleanly. Strike a row when the upstream PR merges. + +| Landed | Change | Fork commit | Upstream status | +| --- | --- | --- | --- | +| 2026-09-12 | `notify_event` delivers to every matching subscription on a connection instead of stopping at the first match (breaks any multiplexed client, e.g. nostrclient + nwcprovider) | see `git log -- relay/client_connection.py` | not yet proposed; upstream `a87bc1f` has the same first-match `return True` | diff --git a/relay/client_connection.py b/relay/client_connection.py index b9c9708..fae522e 100644 --- a/relay/client_connection.py +++ b/relay/client_connection.py @@ -72,16 +72,24 @@ class NostrClientConnection: if self._is_private_event_for_other(event): return False + # One connection can hold several subscriptions whose filters all match + # the same event: a multiplexer such as nostrclient funnels every one of + # its clients through a single connection. NIP-01 requires the event to + # reach each matching subscription, so deliver once per subscription id + # rather than stopping at the first hit. + notified: set[str] = set() for nostr_filter in self.filters: + sub_id = nostr_filter.subscription_id + if sub_id in notified: + continue if nostr_filter.matches(event): - resp = event.serialize_response(nostr_filter.subscription_id) - await self._send_msg(resp) - return True + notified.add(sub_id) # type: ignore[arg-type] + await self._send_msg(event.serialize_response(sub_id)) else: - logger.info( + logger.debug( f"[NOSTRRELAY CLIENT] ❌ Filter didn't match for event {event.id}" ) - return False + return len(notified) > 0 def _is_private_event_for_other(self, event: NostrEvent) -> bool: """ diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..a3de80b --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,82 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ..relay.client_connection import NostrClientConnection +from ..relay.event import NostrEvent +from ..relay.filter import NostrFilter +from ..relay.relay import RelaySpec + +RELAY_ID = "relay_notify" +WALLET = "1111111111111111111111111111111111111111111111111111111111111111" +CLIENT = "2222222222222222222222222222222222222222222222222222222222222222" +REQUEST_ID = "3" * 64 +SIG = "0" * 128 + + +def _connection() -> 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 _nwc_response() -> NostrEvent: + return NostrEvent( + id="4" * 64, + relay_id=RELAY_ID, + publisher=WALLET, + pubkey=WALLET, + created_at=0, + kind=23195, + tags=[["p", CLIENT], ["e", REQUEST_ID]], + content="ciphertext", + sig=SIG, + ) + + +def _sent_subscription_ids(conn: NostrClientConnection) -> list[str]: + return [call.args[0][1] for call in conn._send_msg.await_args_list] # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_event_reaches_every_matching_subscription(): + """ + Regression: delivery used to stop at the first matching filter, so when a + wallet service (subscribed to its own kind-23195 responses) and a wallet + app shared one connection via nostrclient, the app never got the reply. + """ + conn = _connection() + conn.filters = [ + NostrFilter(subscription_id="wallet-own", kinds=[23195], authors=[WALLET]), + NostrFilter(subscription_id="unrelated", kinds=[1]), + NostrFilter( + subscription_id="app-reply", + kinds=[23195], + **{"#e": [REQUEST_ID], "#p": [CLIENT]}, # type: ignore[arg-type] + ), + ] + + assert await conn.notify_event(_nwc_response()) is True + assert _sent_subscription_ids(conn) == ["wallet-own", "app-reply"] + + +@pytest.mark.asyncio +async def test_subscription_with_several_matching_filters_gets_event_once(): + conn = _connection() + conn.filters = [ + NostrFilter(subscription_id="multi", kinds=[23195]), + NostrFilter(subscription_id="multi", authors=[WALLET]), + ] + + assert await conn.notify_event(_nwc_response()) is True + assert _sent_subscription_ids(conn) == ["multi"] + + +@pytest.mark.asyncio +async def test_no_matching_filter_sends_nothing(): + conn = _connection() + conn.filters = [NostrFilter(subscription_id="other", kinds=[1])] + + assert await conn.notify_event(_nwc_response()) is False + conn._send_msg.assert_not_awaited() # type: ignore[attr-defined]