diff --git a/__init__.py b/__init__.py index daff156..2d0ebcf 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,6 @@ from lnbits.tasks import create_permanent_unique_task from loguru import logger from .crud import db -from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices from .views import satmachineadmin_generic_router from .views_api import satmachineadmin_api_router @@ -51,12 +50,6 @@ def satmachineadmin_start(): "ext_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events ) scheduled_tasks.append(cassette_task) - # Path-B wallet-routing hook (#20 / coord-log 2026-05-31T15:25Z): - # register our ATM-roster resolver with lnbits' nostr-transport so - # inbound kind-21000 from a known ATM npub routes to the operator's - # wallet, not an auto-created machine wallet. Soft-fails on lnbits - # versions that don't yet expose `register_roster_resolver`. - register_roster_with_lnbits() __all__ = [ diff --git a/nostr_transport_roster.py b/nostr_transport_roster.py deleted file mode 100644 index 6603e50..0000000 --- a/nostr_transport_roster.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Roster-resolver hook for the path-B wallet-routing fix -(aiolabs/satmachineadmin#20 / lnbits-side issue forthcoming per -coord-log 2026-05-31T15:25Z). - -Exposes a `resolve(sender_pubkey_hex)` function that, given an inbound -NIP-46 sender pubkey, looks it up against `dca_machines.machine_npub` -and returns a `RouteHit(operator_user_id, wallet_id, source_extension)` -on a match. - -The hook is registered with lnbits' `nostr_transport` at extension-init -time via `register_with_lnbits()`. Until the lnbits side ships -`lnbits.core.services.nostr_transport.register_roster_resolver`, the -registration call lazily-imports + soft-fails so satmachineadmin keeps -loading cleanly on any lnbits version. - -When the lnbits implementation lands + the satmachine instance has -`NOSTR_TRANSPORT_ROSTER_REQUIRED=true` set, inbound kind-21000 -RPCs from a registered ATM npub will route directly to the operator's -wallet (delivering the "cash-out sats go to the operator's wallet, not -an auto-created machine wallet" outcome). Unregistered npubs get -rejected with the fail-closed posture user chose at coord-log -2026-05-31T14:38Z. - -Field-shape contract for `RouteHit` is FROZEN per coord-log -2026-05-31T15:25Z lnbits ack: `(operator_user_id, wallet_id, -source_extension)`. Don't add fields here without a coord-log round — -the shape is a multi-extension API contract. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -from lnbits.utils.nostr import normalize_public_key -from loguru import logger - -from .crud import get_machine_by_atm_pubkey_hex - -_SOURCE_EXTENSION = "satmachineadmin" - - -@dataclass(frozen=True) -class RouteHit: - """A positive answer from a roster resolver: route the resulting - invoice to (operator_user_id, wallet_id). `source_extension` - identifies which roster matched — used by lnbits for loud-reject - logging when the failure-mode posture rejects. - - Local definition mirrors the agreed lnbits-side shape per coord-log - 2026-05-31T15:25Z. When lnbits' canonical class is importable, - `register_with_lnbits` prefers it over this local one — but the - local stays as a fallback so this module imports cleanly on pre- - landing lnbits versions + drives the unit tests. - """ - - operator_user_id: str - wallet_id: str - source_extension: str = _SOURCE_EXTENSION - - -async def resolve(sender_pubkey_hex: str) -> RouteHit | None: - """Roster lookup: given a sender pubkey from an inbound nostr- - transport RPC, return a RouteHit if it's a registered ATM, None - otherwise. - - Canonicalises the input first (sender pubkeys arrive lowercase-hex - from `Payment.extra.nostr_sender_pubkey` per lnbits PR #4, but - upstream is paranoid — normalise just in case). - - Raises on a malformed pubkey input — lnbits' fail-closed posture - (option b at coord-log 2026-05-31T14:38Z, ack'd at 15:15Z item 2 - sub-case "resolver raises an exception → reject + ERROR log") - means this surfaces as a rejection, not a silent fall-through. - Same handling as any other unrecoverable resolver error. - """ - canonical = normalize_public_key(sender_pubkey_hex).lower() - machine = await get_machine_by_atm_pubkey_hex(canonical) - if machine is None: - return None - return _build_route_hit( - operator_user_id=machine.operator_user_id, - wallet_id=machine.wallet_id, - ) - - -def _build_route_hit(operator_user_id: str, wallet_id: str): - """Construct a RouteHit using lnbits' canonical class if importable, - otherwise the local fallback. Centralised so a future lnbits-side - shape evolution only touches this helper.""" - try: - from lnbits.core.services.nostr_transport import ( # type: ignore[attr-defined] - RouteHit as _LnbitsRouteHit, - ) - except ImportError: - return RouteHit( - operator_user_id=operator_user_id, - wallet_id=wallet_id, - source_extension=_SOURCE_EXTENSION, - ) - return _LnbitsRouteHit( - operator_user_id=operator_user_id, - wallet_id=wallet_id, - source_extension=_SOURCE_EXTENSION, - ) - - -def register_with_lnbits() -> bool: - """Register `resolve` with lnbits' nostr-transport roster registry. - - Returns True if the registration landed (lnbits surface available - + call succeeded), False if soft-failed because lnbits hasn't - shipped `register_roster_resolver` yet — that's the expected - state until the path-B lnbits PR lands. Either way satmachineadmin - boots cleanly; only the routing-via-roster behavior is gated on - the lnbits side being present. - - Called once from `satmachineadmin_start()`. Idempotent on the - lnbits side per their 15:15Z spec ("re-registration on extension - reload replaces cleanly"). - """ - try: - from lnbits.core.services.nostr_transport import ( # type: ignore[attr-defined] - register_roster_resolver, - ) - except ImportError: - logger.info( - "satmachineadmin: nostr-transport roster-resolver hook not " - "available on this lnbits version (pre-path-B); ATM-npub " - "routing falls through to lnbits' default auto-account-from-" - "npub behaviour. See aiolabs/satmachineadmin#20 / coord-log " - "2026-05-31T15:25Z for the path-B handoff." - ) - return False - register_roster_resolver(_SOURCE_EXTENSION, resolve) - logger.info( - f"satmachineadmin: registered '{_SOURCE_EXTENSION}' roster " - "resolver with lnbits nostr-transport — inbound kind-21000 " - "from a registered ATM npub will route to the operator's wallet " - "directly. (Behavior gated server-side by " - "NOSTR_TRANSPORT_ROSTER_REQUIRED.)" - ) - return True diff --git a/tests/test_roster_resolver.py b/tests/test_roster_resolver.py deleted file mode 100644 index 70eb2d3..0000000 --- a/tests/test_roster_resolver.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Tests for `nostr_transport_roster.resolve` — the lookup function -satmachineadmin hands lnbits' nostr-transport via -`register_roster_resolver` (path-B wallet-routing fix, #20 / -coord-log 2026-05-31T15:25Z). - -Verifies: - - Known ATM npub → RouteHit with operator_user_id + wallet_id from - the machine row - - Unknown sender → None (lnbits falls back to its other resolvers, - or fail-closed rejection per the env-gated posture) - - bech32 input is normalised to hex before lookup - - Uppercase hex input is normalised to lowercase before lookup - - Malformed input raises (fail-closed sub-case per lnbits 15:15Z ack) - -`register_with_lnbits` is also smoke-tested for the soft-fail branch -that fires on lnbits versions without `register_roster_resolver`. The -positive (lnbits hook present) branch needs a live lnbits import + -will be covered once the lnbits-side PR lands. - -Coroutines driven via `asyncio.run` per project convention (no pytest- -asyncio plugin in CI; see test_cassette_state_consumer.py header). -""" - -import asyncio -import sys -from types import SimpleNamespace - -import coincurve -import pytest -from lnbits.utils.nostr import hex_to_npub - -from .. import nostr_transport_roster as roster -from ..nostr_transport_roster import register_with_lnbits, resolve - -_ATM_SEC = "00" * 31 + "02" -_ATM_PUB_HEX = ( - coincurve.PrivateKey(bytes.fromhex(_ATM_SEC)) - .public_key.format(compressed=True)[1:] - .hex() -) -_ATM_PUB_NPUB = hex_to_npub(_ATM_PUB_HEX) - - -def _fake_machine(operator_user_id: str, wallet_id: str, npub_hex: str): - return SimpleNamespace( - operator_user_id=operator_user_id, - wallet_id=wallet_id, - machine_npub=npub_hex, - ) - - -def test_resolve_known_atm_returns_route_hit(monkeypatch): - captured: dict[str, str] = {} - - async def _fake_lookup(pubkey_hex: str): - captured["pubkey_hex"] = pubkey_hex - return _fake_machine( - operator_user_id="op-123", - wallet_id="wallet-abc", - npub_hex=_ATM_PUB_HEX, - ) - - monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup) - - result = asyncio.run(resolve(_ATM_PUB_HEX)) - - # `_build_route_hit` prefers lnbits' canonical `RouteHit` when - # importable + falls back to our local class otherwise; assert on - # the frozen field-shape contract (coord-log 2026-05-31T15:25Z), - # not the specific class identity, so the test passes against - # both lnbits versions. - assert result is not None - assert result.operator_user_id == "op-123" - assert result.wallet_id == "wallet-abc" - assert result.source_extension == "satmachineadmin" - assert captured["pubkey_hex"] == _ATM_PUB_HEX - - -def test_resolve_unknown_sender_returns_none(monkeypatch): - async def _no_match(pubkey_hex: str): - return None - - monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _no_match) - - result = asyncio.run(resolve(_ATM_PUB_HEX)) - - assert result is None - - -def test_resolve_canonicalises_bech32_to_hex(monkeypatch): - """Sender pubkeys arrive lowercase-hex from lnbits PR #4, but the - resolver is paranoid: a bech32 input must still hit the hex-keyed - crud lookup.""" - captured: dict[str, str] = {} - - async def _fake_lookup(pubkey_hex: str): - captured["pubkey_hex"] = pubkey_hex - return _fake_machine( - operator_user_id="op-bech32", - wallet_id="wallet-bech32", - npub_hex=_ATM_PUB_HEX, - ) - - monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup) - - result = asyncio.run(resolve(_ATM_PUB_NPUB)) - - assert result is not None - assert captured["pubkey_hex"] == _ATM_PUB_HEX - - -def test_resolve_lowercases_uppercase_hex(monkeypatch): - captured: dict[str, str] = {} - - async def _fake_lookup(pubkey_hex: str): - captured["pubkey_hex"] = pubkey_hex - return None - - monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _fake_lookup) - - asyncio.run(resolve(_ATM_PUB_HEX.upper())) - - assert captured["pubkey_hex"] == _ATM_PUB_HEX - - -def test_resolve_raises_on_malformed_input(monkeypatch): - """Fail-closed sub-case per lnbits 15:15Z ack item 2: resolver - raising an exception surfaces to lnbits as a reject + ERROR log, - NOT a silent fall-through to auto-account creation.""" - - async def _unreachable(pubkey_hex: str): - raise AssertionError("crud must not be reached for malformed input") - - monkeypatch.setattr(roster, "get_machine_by_atm_pubkey_hex", _unreachable) - - with pytest.raises((ValueError, AssertionError)): - asyncio.run(resolve("not-a-pubkey")) - - -def test_register_with_lnbits_soft_fails_without_hook(monkeypatch): - """Until the lnbits-side path-B PR lands, the registration call - must soft-fail cleanly (returns False, no exception) so - satmachineadmin keeps booting on every lnbits version.""" - real_import = ( - __builtins__["__import__"] - if isinstance(__builtins__, dict) - else __builtins__.__import__ - ) - - def _faulty_import(name, *args, **kwargs): - if name == "lnbits.core.services.nostr_transport": - raise ImportError("simulated: pre-path-B lnbits") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr("builtins.__import__", _faulty_import) - # Drop any cached import so the lazy `from … import …` inside - # register_with_lnbits re-triggers the import statement. - monkeypatch.delitem( - sys.modules, "lnbits.core.services.nostr_transport", raising=False - ) - - assert register_with_lnbits() is False