mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge branch 'main' into lightning-refund
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -151,8 +151,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
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from cashu.core.base import MeltQuoteState
|
||||
from cashu.core.settings import settings as cashu_settings
|
||||
from cashu.wallet.wallet import Proof, Wallet
|
||||
|
||||
from ..cashu_compat import install_cashu_httpx_shim
|
||||
@@ -291,13 +292,20 @@ def _select_melt_proofs(
|
||||
Cashu 0.20's ``select_to_send`` may recursively swap when asked to spend a
|
||||
wallet's full balance. Melts accept overpayment and return change, so a
|
||||
bounded, largest-first selection is both safer and minimizes input fees.
|
||||
|
||||
Mints reject a melt carrying more than ``mint_max_request_length`` inputs,
|
||||
so a dust-heavy wallet can only pay what its largest inputs cover; the
|
||||
caller lowers the amount and the rest goes out on later payouts.
|
||||
"""
|
||||
selected: list[Proof] = []
|
||||
selected_amount = 0
|
||||
required = quote_amount + fee_reserve
|
||||
for proof in sorted(proofs, key=lambda item: item.amount, reverse=True):
|
||||
if getattr(proof, "reserved", False) is True:
|
||||
continue
|
||||
spendable = [
|
||||
proof
|
||||
for proof in sorted(proofs, key=lambda item: item.amount, reverse=True)
|
||||
if getattr(proof, "reserved", False) is not True
|
||||
]
|
||||
for proof in spendable[: cashu_settings.mint_max_request_length]:
|
||||
selected.append(proof)
|
||||
selected_amount += proof.amount
|
||||
input_fees = int(wallet.get_fees_for_proofs(selected))
|
||||
|
||||
@@ -164,15 +164,15 @@ async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
|
||||
async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion() -> (
|
||||
None
|
||||
):
|
||||
proofs = [MagicMock(amount=1, reserved=False) for _ in range(1500)]
|
||||
proofs = [MagicMock(amount=1, reserved=False) for _ in range(500)]
|
||||
wallet = MagicMock(url="https://mint.test")
|
||||
wallet.get_fees_for_proofs = MagicMock(
|
||||
side_effect=lambda selected: math.ceil(len(selected) / 100)
|
||||
)
|
||||
wallet.melt_quote = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(fee_reserve=10, quote="q1", amount=1500),
|
||||
MagicMock(fee_reserve=10, quote="q2", amount=1475),
|
||||
MagicMock(fee_reserve=10, quote="q1", amount=500),
|
||||
MagicMock(fee_reserve=10, quote="q2", amount=485),
|
||||
]
|
||||
)
|
||||
wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
|
||||
@@ -186,17 +186,17 @@ async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion
|
||||
proofs,
|
||||
"owner@ln.tld",
|
||||
"sat",
|
||||
amount=1500,
|
||||
amount=500,
|
||||
on_melt_quote=checkpoint,
|
||||
)
|
||||
|
||||
assert paid == 1_475_000
|
||||
assert paid == 485_000
|
||||
assert wallet.melt_quote.await_count == 2
|
||||
checkpoint.assert_awaited_once_with("q2")
|
||||
wallet.select_to_send.assert_not_called()
|
||||
selected = wallet.melt.await_args.kwargs["proofs"]
|
||||
assert sum(proof.amount for proof in selected) == 1500
|
||||
assert 1475 + 10 + wallet.get_fees_for_proofs(selected) == 1500
|
||||
assert sum(proof.amount for proof in selected) == 500
|
||||
assert 485 + 10 + wallet.get_fees_for_proofs(selected) == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -421,3 +421,63 @@ def test_select_melt_proofs_ignores_fees_for_unneeded_wallet_proofs() -> None:
|
||||
assert selected is None
|
||||
assert shortfall == 2
|
||||
assert wallet.get_fees_for_proofs.call_count == 1
|
||||
|
||||
|
||||
def test_select_melt_proofs_respects_mint_input_limit() -> None:
|
||||
from cashu.core.settings import settings as cashu_settings
|
||||
|
||||
from routstr.payment.lnurl import _select_melt_proofs
|
||||
|
||||
limit = cashu_settings.mint_max_request_length
|
||||
wallet = MagicMock()
|
||||
wallet.get_fees_for_proofs = MagicMock(return_value=0)
|
||||
proofs = [MagicMock(amount=1, reserved=False) for _ in range(limit + 563)]
|
||||
|
||||
selected, shortfall = _select_melt_proofs(
|
||||
wallet,
|
||||
proofs,
|
||||
quote_amount=limit + 563,
|
||||
fee_reserve=0,
|
||||
gross_budget=limit + 563,
|
||||
)
|
||||
|
||||
assert selected is None
|
||||
assert shortfall == 563
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raw_send_to_lnurl_pays_what_the_input_limit_allows() -> None:
|
||||
from cashu.core.settings import settings as cashu_settings
|
||||
|
||||
limit = cashu_settings.mint_max_request_length
|
||||
proofs = [MagicMock(amount=1, reserved=False) for _ in range(limit + 563)]
|
||||
wallet = MagicMock(url="https://mint.test")
|
||||
wallet.get_fees_for_proofs = MagicMock(return_value=0)
|
||||
wallet.melt = AsyncMock(return_value=MagicMock(state=MeltQuoteState.paid))
|
||||
wallet.set_reserved_for_send = AsyncMock()
|
||||
|
||||
requested: list[int] = []
|
||||
|
||||
async def invoice(_callback: str, amount_msat: int) -> tuple[str, dict]:
|
||||
requested.append(amount_msat)
|
||||
return "lnbc1...", {}
|
||||
|
||||
async def melt_quote(invoice: str) -> MagicMock:
|
||||
return MagicMock(fee_reserve=0, quote="q", amount=requested[-1] // 1000)
|
||||
|
||||
wallet.melt_quote = AsyncMock(side_effect=melt_quote)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"routstr.payment.lnurl.get_lnurl_data", AsyncMock(return_value=LNURL_DATA)
|
||||
),
|
||||
patch(
|
||||
"routstr.payment.lnurl.get_lnurl_invoice", AsyncMock(side_effect=invoice)
|
||||
),
|
||||
):
|
||||
paid = await raw_send_to_lnurl(
|
||||
wallet, proofs, "owner@ln.tld", "sat", amount=limit + 563
|
||||
)
|
||||
|
||||
assert paid == limit * 1000
|
||||
assert len(wallet.melt.await_args.kwargs["proofs"]) == limit
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user