Merge pull request #734 from Routstr/fix/nsec-save-publishes-38421

fix: announce provider as soon as the NSEC is saved
This commit is contained in:
9qeklajc
2026-09-17 00:52:02 +02:00
committed by GitHub
4 changed files with 344 additions and 137 deletions
+13
View File
@@ -9,6 +9,19 @@ Routstr uses **Nostr** as a decentralized directory for service discovery. Your
1. **Provider Advertisement (Kind 38421)**: Your node periodically publishes an event with its URL, models, and pricing
2. **Client Discovery**: Clients query relays for these events to find suitable providers
### When announcements are published
Your node publishes an advertisement as soon as it has both a **Nsec** and at least one
reachable endpoint (a public `HTTP_URL`, or an `.onion` address). Saving the Nsec in the
dashboard is enough — the announcement follows within a minute, and **no restart is
required**. After the first publish it re-announces every 24 hours, and immediately
whenever the Nsec, endpoints, mints or relays change.
A node that has no Nsec yet simply waits, and starts announcing the moment one is
configured. Note that `HTTP_URL` defaults to `http://localhost:8000`, which is not a
reachable endpoint: a node with the default value and no onion address has nothing to
advertise and will not publish until one is set.
---
## Configuration
+4 -2
View File
@@ -149,8 +149,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
refresh_model_paths_periodically(get_upstreams)
)
payout_task = asyncio.create_task(periodic_payout())
if global_settings.nsec:
nip91_task = asyncio.create_task(announce_provider())
# Always started: the loop idles until an NSEC is configured and re-reads
# it every iteration, so a key saved (or cleared) through the admin UI
# takes effect without a restart.
nip91_task = asyncio.create_task(announce_provider())
analytics_task = asyncio.create_task(publish_usage_analytics())
if global_settings.providers_refresh_interval_seconds > 0:
providers_task = asyncio.create_task(providers_cache_refresher())
+151 -135
View File
@@ -269,94 +269,93 @@ async def publish_to_relay(
return False
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = settings.nsec
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping listing announcement")
return
# Re-announce cadence once a provider is listed.
ANNOUNCEMENT_INTERVAL_SECONDS = 24 * 60 * 60
# Poll cadence while there is nothing to announce (no NSEC, no endpoint, ...).
DISABLED_POLL_SECONDS = 60
# How often the long re-announce sleep re-checks the configured NSEC, so a
# newly saved identity is announced promptly instead of up to 24h later.
IDENTITY_POLL_SECONDS = 30
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping listing announcement")
return
DEFAULT_RELAY_URLS = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Resolve settings and determine if we can publish BEFORE touching relays
try:
base_url: str | None = settings.http_url
onion_url: str | None = settings.onion_url
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
except Exception:
base_url = settings.http_url or None
onion_url = settings.onion_url or None
provider_name = settings.name or "Routstr Proxy"
provider_about = settings.description or "Privacy-preserving AI proxy via Nostr"
cashu_mints = [m.strip() for m in settings.cashu_mints if m.strip()]
def _resolve_endpoint_urls() -> list[str]:
"""Endpoints to advertise: a public HTTP URL and/or an onion URL."""
endpoint_urls: list[str] = []
base_url = (settings.http_url or "").strip()
if base_url and base_url != "http://localhost:8000":
endpoint_urls.append(base_url)
onion_url = (settings.onion_url or "").strip()
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
mint_urls = cashu_mints if cashu_mints else None
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
if onion_url and onion_url.strip():
ou = onion_url.strip()
if ou.endswith(".onion") and not (
ou.startswith("http://") or ou.startswith("https://")
if onion_url:
if onion_url.endswith(".onion") and not (
onion_url.startswith("http://") or onion_url.startswith("https://")
):
ou = f"http://{ou}"
endpoint_urls.append(ou)
onion_url = f"http://{onion_url}"
endpoint_urls.append(onion_url)
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping listing publish."
)
return
return endpoint_urls
# Only now configure relays and determine provider_id (may query relays)
def _resolve_relay_urls() -> list[str]:
relay_urls = [u.strip() for u in getattr(settings, "relays", []) if u.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
"wss://nos.lol",
]
return relay_urls or list(DEFAULT_RELAY_URLS)
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Build metadata
metadata = {
"name": provider_name,
"about": provider_about,
}
def _resolve_mint_urls() -> list[str] | None:
mints = [m.strip() for m in (settings.cashu_mints or []) if m.strip()]
return mints or None
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Backoff configuration and state (sensible defaults)
async def _sleep_until_next_announcement(
seconds: float, parsed_nsec: str | None
) -> None:
"""Sleep up to ``seconds``, returning early if the configured NSEC changes.
Without the early wake, a node runner who replaces the NSEC in the admin UI
would wait out the whole re-announce interval before the new identity (and,
with it, the new ``d`` tag and npub) is announced.
"""
remaining = float(seconds)
while remaining > 0:
if (settings.nsec or "").strip() != (parsed_nsec or ""):
return
chunk = min(float(IDENTITY_POLL_SECONDS), remaining)
await asyncio.sleep(chunk)
remaining -= chunk
async def announce_provider() -> None:
"""Background task announcing this Routstr provider to Nostr relays.
Started unconditionally at boot: while the node has no NSEC the task idles
and re-checks, so an identity configured later through the admin UI is
picked up (and announced) without a restart. The identity, endpoints, mints
and relays are all re-read every iteration, mirroring
``publish_usage_analytics``.
"""
parsed_nsec: str | None = None
private_key_hex: str | None = None
public_key_hex: str | None = None
provider_id: str | None = None
warned_missing_nsec = False
# Backoff state is deliberately long-lived: it has to survive an idle poll,
# a full re-announce cycle and an identity change, otherwise a failing relay
# would be retried at full rate on every pass.
backoff_base = 5.0
backoff_max = 900.0
backoff_jitter_ratio = 0.2
@@ -381,67 +380,74 @@ async def announce_provider() -> None:
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_listing_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
# Decide whether to publish: publish if none exist or any differ from candidate
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if not all_match:
logger.debug(
"No matching listing announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
f"Published listing announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching listing announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
announcement_interval = 24 * 60 * 60
while True:
try:
await asyncio.sleep(announcement_interval)
nsec = (settings.nsec or "").strip()
if not nsec:
if not warned_missing_nsec:
logger.info(
"Nostr private key not configured (NSEC); waiting for one "
"to be set before announcing this provider"
)
warned_missing_nsec = True
parsed_nsec = None
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
# Re-derive the identity whenever the configured NSEC changes, so a
# key saved (or replaced) through the admin UI takes effect live.
if nsec != parsed_nsec:
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error(
"Invalid NSEC; waiting for a valid one before announcing"
)
parsed_nsec = None
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
private_key_hex, public_key_hex = keypair
parsed_nsec = nsec
provider_id = None
warned_missing_nsec = False
logger.info(f"Using Nostr pubkey: {public_key_hex}")
if private_key_hex is None or public_key_hex is None:
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
endpoint_urls = _resolve_endpoint_urls()
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). "
"Skipping listing publish until one is set."
)
await asyncio.sleep(DISABLED_POLL_SECONDS)
continue
relay_urls = _resolve_relay_urls()
if provider_id is None:
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
metadata = {
"name": settings.name or "Routstr Proxy",
"about": settings.description
or "Privacy-preserving AI proxy via Nostr",
}
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_listing_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
mint_urls=_resolve_mint_urls(),
version=get_app_version(),
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events = []
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
@@ -462,26 +468,36 @@ async def announce_provider() -> None:
if all_match:
logger.debug(
"Matching listing announcement already present; skipping periodic re-announce"
"Matching listing announcement already present; skipping publish"
)
else:
logger.debug(
"No matching listing announcement found or differences "
"detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
"Published listing announcement to "
f"{success_count}/{len(relay_urls)} relays"
)
continue
logger.debug(
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
# Re-announce periodically; wakes early if the NSEC changes.
await _sleep_until_next_announcement(
ANNOUNCEMENT_INTERVAL_SECONDS, parsed_nsec
)
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
ok = await publish_to_relay(relay_url, candidate_event)
if ok:
_register_success(relay_url)
else:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("Listing announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in listing announcement loop: {type(e).__name__}")
# Continue running despite errors
await asyncio.sleep(DISABLED_POLL_SECONDS)
+176
View File
@@ -0,0 +1,176 @@
"""Tests for the kind 38421 provider announcement loop.
The regression these cover: the node runner configures the NSEC through the
admin UI (not the ``.env`` file), which only mutates the live ``settings``
singleton. The announcement task must therefore (a) already be running, and
(b) pick the new identity up on its own — without a process restart.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from routstr.nostr import listing
NSEC_A = "11" * 32
NSEC_B = "22" * 32
def _quiet_settings(monkeypatch: Any, nsec: str = "") -> None:
"""Pin the settings the loop reads, and keep it off the network/Tor."""
monkeypatch.setattr(listing.settings, "nsec", nsec)
monkeypatch.setattr(listing.settings, "http_url", "https://node.example.com")
monkeypatch.setattr(listing.settings, "onion_url", "")
monkeypatch.setattr(listing.settings, "relays", [])
monkeypatch.setattr(listing.settings, "provider_id", "testprovider")
monkeypatch.setattr(listing.settings, "cashu_mints", [])
monkeypatch.setattr(listing, "discover_onion_url_from_tor", lambda: None)
def _capture_publishes(monkeypatch: Any) -> list[dict[str, Any]]:
published: list[dict[str, Any]] = []
async def fake_query(
*args: Any, **kwargs: Any
) -> tuple[list[dict[str, Any]], bool]:
return [], True
async def fake_publish(
relay_url: str, event: dict[str, Any], timeout: int = 30
) -> bool:
published.append(event)
return True
monkeypatch.setattr(listing, "query_listing_events", fake_query)
monkeypatch.setattr(listing, "publish_to_relay", fake_publish)
return published
def _distinct_events(published: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""One entry per announcement; the publisher is invoked once per relay."""
by_id: dict[str, dict[str, Any]] = {}
for event in published:
by_id[event["id"]] = event
return list(by_id.values())
@pytest.mark.asyncio
async def test_announce_provider_idles_without_nsec_then_publishes_when_saved(
monkeypatch: Any,
) -> None:
"""The reported bug: NSEC saved through the admin UI must get announced."""
sleeps: list[float] = []
published = _capture_publishes(monkeypatch)
_quiet_settings(monkeypatch, nsec="")
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
if len(sleeps) == 1:
# The node runner hits "save" in the admin UI: the nsec appears on
# the live settings singleton while the task is already idling.
monkeypatch.setattr(listing.settings, "nsec", NSEC_A)
return
raise asyncio.CancelledError()
monkeypatch.setattr(listing.asyncio, "sleep", fake_sleep)
await listing.announce_provider()
# First pass idles (no nsec), then the announcing pass sleeps one poll tick
# into the re-announce interval.
assert sleeps == [
listing.DISABLED_POLL_SECONDS,
listing.IDENTITY_POLL_SECONDS,
]
announcements = _distinct_events(published)
assert len(announcements) == 1
assert announcements[0]["kind"] == 38421
@pytest.mark.asyncio
async def test_announce_provider_reannounces_when_nsec_is_replaced(
monkeypatch: Any,
) -> None:
"""Replacing the key must re-resolve the identity and announce again."""
sleeps: list[float] = []
published = _capture_publishes(monkeypatch)
_quiet_settings(monkeypatch, nsec=NSEC_A)
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
if len(sleeps) == 1:
monkeypatch.setattr(listing.settings, "nsec", NSEC_B)
return
raise asyncio.CancelledError()
monkeypatch.setattr(listing.asyncio, "sleep", fake_sleep)
await listing.announce_provider()
assert len(published) == 2 * len(listing.DEFAULT_RELAY_URLS)
announcements = _distinct_events(published)
assert len(announcements) == 2
pubkeys = {event["pubkey"] for event in announcements}
assert len(pubkeys) == 2, "each identity must be announced under its own pubkey"
@pytest.mark.asyncio
async def test_announce_provider_idles_without_endpoints(monkeypatch: Any) -> None:
"""No publishable endpoint: idle instead of exiting, and never publish."""
sleeps: list[float] = []
published = _capture_publishes(monkeypatch)
_quiet_settings(monkeypatch, nsec=NSEC_A)
monkeypatch.setattr(listing.settings, "http_url", "")
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
raise asyncio.CancelledError()
monkeypatch.setattr(listing.asyncio, "sleep", fake_sleep)
await listing.announce_provider()
assert published == []
assert sleeps == [listing.DISABLED_POLL_SECONDS]
@pytest.mark.asyncio
async def test_sleep_until_next_announcement_wakes_on_nsec_change(
monkeypatch: Any,
) -> None:
sleeps: list[float] = []
monkeypatch.setattr(listing.settings, "nsec", NSEC_A)
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr(listing.settings, "nsec", NSEC_B)
monkeypatch.setattr(listing.asyncio, "sleep", fake_sleep)
await listing._sleep_until_next_announcement(
listing.ANNOUNCEMENT_INTERVAL_SECONDS, NSEC_A
)
# Woke on the first poll tick rather than sleeping out the whole interval.
assert sleeps == [listing.IDENTITY_POLL_SECONDS]
@pytest.mark.asyncio
async def test_sleep_until_next_announcement_buckets_a_short_interval(
monkeypatch: Any,
) -> None:
sleeps: list[float] = []
monkeypatch.setattr(listing.settings, "nsec", NSEC_A)
async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
monkeypatch.setattr(listing.asyncio, "sleep", fake_sleep)
await listing._sleep_until_next_announcement(45, NSEC_A)
# 45s interval: a full 30s tick, then the 15s remainder (never overshoots).
assert sleeps == [listing.IDENTITY_POLL_SECONDS, 15]