diff --git a/docs/provider/discovery.md b/docs/provider/discovery.md index c0d06036..63e6caaf 100644 --- a/docs/provider/discovery.md +++ b/docs/provider/discovery.md @@ -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 diff --git a/routstr/core/main.py b/routstr/core/main.py index 938e2144..f6774358 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -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()) diff --git a/routstr/nostr/listing.py b/routstr/nostr/listing.py index 9e6ec572..02a668eb 100644 --- a/routstr/nostr/listing.py +++ b/routstr/nostr/listing.py @@ -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) diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index caf551db..d5fb7e89 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -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)) diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py index a8e08dfb..72986500 100644 --- a/tests/unit/test_lnurl_amount_and_destination.py +++ b/tests/unit/test_lnurl_amount_and_destination.py @@ -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 diff --git a/tests/unit/test_nostr_listing_announce.py b/tests/unit/test_nostr_listing_announce.py new file mode 100644 index 00000000..69bc578e --- /dev/null +++ b/tests/unit/test_nostr_listing_announce.py @@ -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]