feat: reply OK to client-published events

nostrclient never answered a client's EVENT with the NIP-01
`["OK", <id>, <accepted>, <message>]` command result. Clients built on
nostr-tools and similar libraries wait for that reply before treating a
publish as successful, so NWC wallet apps paired through the public
endpoint reported "publish failed" even though the request had been
fanned out and answered.

Relay OKs now flow through the message pool like events and notices.
The router tracks each EVENT a client publishes and replies exactly
once: `true` as soon as any relay accepts, `false` once every relay
connected at publish time has rejected it, after a 10 s timeout, or
immediately when no relay is connected. OKs nobody is waiting on are
dropped at the pump so the shared result map cannot grow unbounded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tbyw6FwjhEJg3gHfPHxWt
This commit is contained in:
Padreug 2026-09-12 13:31:00 +02:00
commit 7aa0ce14db
6 changed files with 277 additions and 4 deletions

141
tests/test_router_ok.py Normal file
View file

@ -0,0 +1,141 @@
import json
import time
import pytest
from .. import router as router_module
from ..nostr.message_pool import CommandResultMessage
from ..router import PUBLISH_TIMEOUT_SECONDS, NostrRouter
class FakeWebSocket:
def __init__(self):
self.sent: list[list] = []
async def send_text(self, text: str):
self.sent.append(json.loads(text))
class FakeRelay:
def __init__(self, connected: bool):
self.connected = connected
class FakeRelayManager:
def __init__(self, relays: dict[str, FakeRelay]):
self.relays = relays
self.published: list[str] = []
def publish_message(self, message: str):
self.published.append(message)
def close_subscriptions(self, subscriptions):
pass
EVENT_ID = "ab" * 32
EVENT_MSG = json.dumps(["EVENT", {"id": EVENT_ID, "kind": 1, "content": "hi"}])
def _router(monkeypatch, relays: dict[str, FakeRelay]):
manager = FakeRelayManager(relays)
monkeypatch.setattr(router_module.nostr_client, "relay_manager", manager)
NostrRouter.received_command_results.clear()
ws = FakeWebSocket()
return NostrRouter(ws), ws, manager # type: ignore[arg-type]
def _ok_from(url: str, accepted: bool, message: str = ""):
NostrRouter.received_command_results.setdefault(EVENT_ID, []).append(
CommandResultMessage(EVENT_ID, accepted, message, url)
)
@pytest.mark.asyncio
async def test_no_connected_relays_replies_ok_false_immediately(monkeypatch):
router, ws, manager = _router(monkeypatch, {"wss://a": FakeRelay(False)})
await router._handle_client_to_nostr(EVENT_MSG)
assert manager.published == []
assert ws.sent == [["OK", EVENT_ID, False, "error: no relays connected"]]
assert router.pending_publishes == {}
@pytest.mark.asyncio
async def test_event_without_id_is_ignored(monkeypatch):
router, ws, manager = _router(monkeypatch, {"wss://a": FakeRelay(True)})
await router._handle_client_to_nostr(json.dumps(["EVENT", {"kind": 1}]))
assert manager.published == []
assert ws.sent == []
@pytest.mark.asyncio
async def test_any_accepting_relay_yields_ok_true(monkeypatch):
relays = {"wss://a": FakeRelay(True), "wss://b": FakeRelay(True)}
router, ws, manager = _router(monkeypatch, relays)
await router._handle_client_to_nostr(EVENT_MSG)
assert manager.published == [EVENT_MSG]
assert EVENT_ID in router.pending_publishes
# nothing answered yet: no OK
await router._handle_command_results()
assert ws.sent == []
_ok_from("wss://a", False, "blocked: kind not allowed")
await router._handle_command_results()
assert ws.sent == [] # one rejection out of two relays: keep waiting
_ok_from("wss://b", True, "")
await router._handle_command_results()
assert ws.sent == [["OK", EVENT_ID, True, ""]]
assert router.pending_publishes == {}
assert EVENT_ID not in NostrRouter.received_command_results
# a late OK must not produce a second reply
_ok_from("wss://a", True, "")
await router._handle_command_results()
assert len(ws.sent) == 1
@pytest.mark.asyncio
async def test_all_relays_rejecting_yields_ok_false(monkeypatch):
relays = {"wss://a": FakeRelay(True), "wss://b": FakeRelay(True)}
router, ws, _ = _router(monkeypatch, relays)
await router._handle_client_to_nostr(EVENT_MSG)
_ok_from("wss://a", False, "invalid: bad sig")
_ok_from("wss://b", False, "blocked: kind not allowed")
await router._handle_command_results()
assert ws.sent == [["OK", EVENT_ID, False, "blocked: kind not allowed"]]
assert router.pending_publishes == {}
@pytest.mark.asyncio
async def test_timeout_yields_ok_false(monkeypatch):
router, ws, _ = _router(monkeypatch, {"wss://a": FakeRelay(True)})
await router._handle_client_to_nostr(EVENT_MSG)
router.pending_publishes[EVENT_ID].sent_at = (
time.time() - PUBLISH_TIMEOUT_SECONDS - 1
)
await router._handle_command_results()
assert ws.sent == [["OK", EVENT_ID, False, "error: timed out waiting for relays"]]
assert router.pending_publishes == {}
@pytest.mark.asyncio
async def test_stop_drops_pending_publishes(monkeypatch):
router, _, _ = _router(monkeypatch, {"wss://a": FakeRelay(True)})
await router._handle_client_to_nostr(EVENT_MSG)
_ok_from("wss://a", True)
await router.stop()
assert router.pending_publishes == {}
assert NostrRouter.received_command_results == {}