Compare commits

..

3 commits

Author SHA1 Message Date
cf6db014ca Merge pull request 'feat: answer REQ with CLOSED when no relay is configured' (#5) from feat/closed-on-req into main
Some checks failed
ci.yml / Merge pull request 'feat: answer REQ with CLOSED when no relay is configured' (#5) from feat/closed-on-req into main (push) Failing after 0s
Reviewed-on: #5
2026-09-13 14:12:47 +00:00
c4c1a1c342 chore: bump version to 1.2.0-aio.3
Some checks failed
ci.yml / chore: bump version to 1.2.0-aio.3 (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
2026-09-13 16:07:37 +02:00
7e7120fa82 feat: answer REQ with CLOSED when no relay is configured
Ported from the upstream PR (lnbits/nostrclient#74, idea from #68).
Nothing will ever serve a subscription when nostrclient has no relay
at all, so refuse it per NIP-01 instead of registering it silently.
Keyed on "no relay configured" rather than "none connected": a
configured relay that is merely reconnecting gets the cached
subscription replayed, so refusing the REQ there would be a regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
2026-09-13 16:07:37 +02:00
4 changed files with 44 additions and 4 deletions

View file

@ -50,7 +50,7 @@ flowchart LR
3. **Fan-Out** - Subscription requests are sent to all configured relays
4. **Aggregation** - Events from all relays are collected and deduplicated
5. **Response** - Events are sent back to the client with the original subscription ID
6. **Publish Acknowledgement** - Every `EVENT` a client publishes gets exactly one `["OK", <id>, <accepted>, <message>]` reply (NIP-01): `true` as soon as any relay accepts it, `false` once every relay has rejected it, the wait times out (10 s), or no relay is connected
6. **Publish Acknowledgement** - Every `EVENT` a client publishes gets exactly one `["OK", <id>, <accepted>, <message>]` reply (NIP-01): `true` as soon as any relay accepts it, `false` once every relay has rejected it, the wait times out (10 s), or no relay is connected. A `REQ` while no relay is configured at all is answered with `CLOSED`
## Configuration

View file

@ -1,7 +1,7 @@
{
"name": "Nostr Client",
"short_description": "Nostr relay multiplexer",
"version": "1.2.0-aio.2",
"version": "1.2.0-aio.3",
"tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "1.4.0",

View file

@ -195,7 +195,7 @@ class NostrRouter:
assert len(json_data), "Bad JSON array"
if json_data[0] == "REQ":
self._handle_client_req(json_data)
await self._handle_client_req(json_data)
return
if json_data[0] == "CLOSE":
@ -223,15 +223,29 @@ class NostrRouter:
self.pending_publishes[event_id] = PendingPublish(event_id, len(connected))
nostr_client.relay_manager.publish_message(json_str)
def _handle_client_req(self, json_data):
async def _handle_client_req(self, json_data):
subscription_id = json_data[1]
logger.info(f"New subscription: '{subscription_id}'")
if not nostr_client.relay_manager.relays:
# NIP-01: nothing will ever serve this subscription, so refuse it.
# Only when no relay is configured at all: a configured relay that
# is merely reconnecting gets the cached subscription replayed.
await self._send_closed(subscription_id, "error: no relay connections")
return
subscription_id_rewritten = urlsafe_short_hash()
self.original_subscription_ids[subscription_id_rewritten] = subscription_id
filters = json_data[2:]
nostr_client.relay_manager.add_subscription(subscription_id_rewritten, filters)
async def _send_closed(self, subscription_id: str, message: str):
try:
await self.websocket.send_text(
json.dumps(["CLOSED", subscription_id, message])
)
except Exception as e:
logger.debug(f"Failed to send CLOSED for '{subscription_id}': {e}")
def _handle_client_close(self, subscription_id):
subscription_id_rewritten = next(
(

View file

@ -25,10 +25,14 @@ class FakeRelayManager:
def __init__(self, relays: dict[str, FakeRelay]):
self.relays = relays
self.published: list[str] = []
self.subscriptions: list[str] = []
def publish_message(self, message: str):
self.published.append(message)
def add_subscription(self, subscription_id: str, filters):
self.subscriptions.append(subscription_id)
def close_subscriptions(self, subscriptions):
pass
@ -139,3 +143,25 @@ async def test_stop_drops_pending_publishes(monkeypatch):
assert router.pending_publishes == {}
assert NostrRouter.received_command_results == {}
@pytest.mark.asyncio
async def test_req_without_configured_relays_is_closed(monkeypatch):
router, ws, manager = _router(monkeypatch, {})
await router._handle_client_to_nostr(json.dumps(["REQ", "sub1", {"kinds": [1]}]))
assert ws.sent == [["CLOSED", "sub1", "error: no relay connections"]]
assert router.subscriptions == []
assert manager.subscriptions == []
@pytest.mark.asyncio
async def test_req_with_configured_but_disconnected_relay_is_kept(monkeypatch):
router, ws, manager = _router(monkeypatch, {"wss://a": FakeRelay(False)})
await router._handle_client_to_nostr(json.dumps(["REQ", "sub1", {"kinds": [1]}]))
assert ws.sent == []
assert len(router.subscriptions) == 1
assert len(manager.subscriptions) == 1