fix: announce provider as soon as the NSEC is saved

A node runner who configures the NSEC through the admin UI (rather than the
NSEC env var) was never announced. The kind 38421 publish task was only
created at boot, gated on an NSEC already being present: saving the key
stored it and updated the live settings singleton, but nothing re-launched
the task, so the provider stayed invisible on relays until the process was
restarted.

Start the task unconditionally and restructure announce_provider into the
idle-and-re-read loop that publish_usage_analytics already uses: it waits
while there is no NSEC, re-derives the identity whenever the key changes,
and re-reads endpoints, mints and relays every iteration. The long
re-announce sleep is now ticked so a replaced identity is announced
promptly instead of up to 24h later. Relay backoff state is hoisted so it
survives an idle poll, a full cycle and an identity change.

This also covers the cases that previously killed the task until a restart:
an invalid NSEC, or a node with no reachable endpoint (HTTP_URL left at its
http://localhost:8000 default and no onion address) now idle and recover as
soon as the configuration is fixed.
This commit is contained in:
redshift
2026-09-16 21:39:30 +02:00
parent 5e94c043a8
commit 860266ab0e
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]