diff --git a/__init__.py b/__init__.py index 08ea43a..d40a411 100644 --- a/__init__.py +++ b/__init__.py @@ -6,7 +6,6 @@ from loguru import logger from .cashin_transport import register_create_withdraw_rpc from .crud import db -from .machine_config_transport import register_machine_config_rpc 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 spirekeeper_generic_router @@ -63,11 +62,6 @@ def spirekeeper_start(): # server-side, never client-supplied. Soft-fails if `register_rpc` isn't # exposed by this lnbits. register_create_withdraw_rpc() - # Server-delivered machine config (#41 / bitspire#70 P1): register the - # get_machine_config RPC so a paired ATM pulls its operator pubkey + fee - # config over the transport, leaving "awaiting configuration" with no - # per-machine env provisioning. Soft-fails if register_rpc isn't exposed. - register_machine_config_rpc() __all__ = [ diff --git a/machine_config_transport.py b/machine_config_transport.py deleted file mode 100644 index 338399a..0000000 --- a/machine_config_transport.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Server-delivered machine config: a `get_machine_config` nostr-transport RPC -(aiolabs/spirekeeper#41, client half aiolabs/bitspire#71 — bitspire#70 P1). - -A paired ATM pulls its **operator pubkey + fee config** over the already- -authenticated kind-21000 transport, instead of the operator pubkey being -provisioned into the machine's `.env` and the fee config being learned only from -an operator-signed kind-30078 broadcast. This lets a seed-only machine (blank -`.env`, scanned `spire-seed`) leave "awaiting configuration" with zero -per-machine provisioning. - -Why it's safe with no client-supplied trust: the transport is already -per-machine authenticated — the ATM signs its kind-21000 requests with its -bunker-minted spire key, which is `dca_machines.machine_npub`. So the handler -resolves the exact machine → operator purely from the verified -`request.sender_pubkey` (same lookup as `cashin_transport`), and only ever -returns the caller's OWN config. The reply is NIP-44 v2 encrypted to the sender -by the transport, so no operator-key encryption is needed here. - -Delivering the operator pubkey re-enables the ATM's fees/operator-config/ -management services (which disable themselves with an empty operator allowlist); -delivering the fee config synchronously means the machine doesn't depend on the -replaceable kind-30078 event being fetchable from the relay. The kind-30078 -publish path stays (dual-run) for live mid-run fee updates. -""" - -from __future__ import annotations - -from lnbits.core.crud.users import get_account -from loguru import logger - -from .crud import get_machine_by_atm_pubkey_hex, get_super_config -from .fee_transport import build_fee_payload - -_RPC_NAME = "get_machine_config" - - -async def handle_get_machine_config(auth, request) -> dict: - """nostr-transport RPC handler. `auth` is the roster-resolved auth context - (unused — the machine is identified from the signature, not the wallet); - `request` is a NostrRpcRequest with `sender_pubkey` (verified). - - Returns `{operator_pubkey, fee_config, fiat_code, machine_npub, wallet_id, - created_at}`. `fee_config` is None until the operator has a super-config. - Raises ValueError (→ transport ERROR reply) for an unpaired sender or an - operator with no Nostr pubkey on file.""" - # Identity is the VERIFIED transport sender — never read it from the body. - sender = (request.sender_pubkey or "").lower() - if not sender: - raise ValueError("missing verified sender_pubkey") - - machine = await get_machine_by_atm_pubkey_hex(sender) - if machine is None: - raise ValueError("sender pubkey is not a paired machine") - - account = await get_account(machine.operator_user_id) - if account is None or not account.pubkey: - raise ValueError("operator has no Nostr pubkey on file") - - # Freshness watermark for the ATM's fee-config replay guard: the latest - # updated_at across the two inputs to the fee config (per-machine fractions - # + super-config), so any config change advances it — and a subsequent - # kind-30078 push with a newer created_at still supersedes it. - watermark = int(machine.updated_at.timestamp()) - - fee_config = None - super_config = await get_super_config() - if super_config is not None: - fee_config = build_fee_payload(super_config, machine).to_wire_dict() - watermark = max(watermark, int(super_config.updated_at.timestamp())) - - logger.info( - f"spirekeeper: get_machine_config machine={machine.id} " - f"operator={account.pubkey[:12]}… fee_config=" - f"{'present' if fee_config else 'none'} created_at={watermark}" - ) - return { - "operator_pubkey": account.pubkey, - "fee_config": fee_config, - "fiat_code": machine.fiat_code, - "machine_npub": machine.machine_npub, - "wallet_id": machine.wallet_id, - "created_at": watermark, - } - - -def register_machine_config_rpc() -> None: - """Register `get_machine_config` with the lnbits nostr transport. Soft-fails - if the transport doesn't expose `register_rpc` (older lnbits) — spirekeeper - still boots; ATMs then fall back to env/kind-30078 config.""" - try: - from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore - AUTH_ACCOUNT, - register_rpc, - ) - except ImportError: - logger.warning( - "spirekeeper: nostr-transport register_rpc unavailable; " - "'get_machine_config' not registered (bitspire#70 P1 pull disabled)" - ) - return - register_rpc(_RPC_NAME, handle_get_machine_config, AUTH_ACCOUNT) - logger.info("spirekeeper: registered nostr-transport RPC 'get_machine_config'") diff --git a/tests/test_machine_config_transport.py b/tests/test_machine_config_transport.py deleted file mode 100644 index 03fa48e..0000000 --- a/tests/test_machine_config_transport.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Tests for the get_machine_config nostr-transport RPC handler -(spirekeeper#41 / bitspire#70 P1). The handler resolves the machine from the -verified request.sender_pubkey and returns operator pubkey + fee config; the -crud/account lookups are monkeypatched (no DB).""" - -import asyncio -from datetime import datetime -from types import SimpleNamespace - -import pytest - -from .. import machine_config_transport -from ..machine_config_transport import handle_get_machine_config -from ..models import Machine, SuperConfig - -_NOW = datetime(2026, 7, 2, 12, 0, 0) -_LATER = datetime(2026, 7, 3, 12, 0, 0) -_ATM_HEX = "b1dbceccc6e717f9fb2283bee389080220d719b0e95cc0ab6bf709c23caae6fa" -_OP_HEX = "8eb610540021d3773606068a2077821e9233736d1b6dcd4e4f9a3de59b2eddcdc3" - - -def _machine(updated_at: datetime = _NOW) -> Machine: - return Machine( - id="m1", - operator_user_id="op1", - machine_npub=_ATM_HEX, - wallet_id="w1", - name="sintra", - location=None, - fiat_code="EUR", - is_active=True, - operator_cash_in_fee_fraction=0.05, - operator_cash_out_fee_fraction=0.05, - created_at=_NOW, - updated_at=updated_at, - ) - - -def _super(updated_at: datetime = _NOW) -> SuperConfig: - return SuperConfig( - id="default", - super_cash_in_fee_fraction=0.03, - super_cash_out_fee_fraction=0.03, - super_fee_wallet_id="super-wallet", - updated_at=updated_at, - ) - - -def _wire(monkeypatch, *, machine, pubkey: object = _OP_HEX, super_config): - async def fake_get_machine(_hex): - return machine - - async def fake_get_account(_uid): - return None if pubkey is _MISSING else SimpleNamespace(pubkey=pubkey) - - async def fake_get_super(): - return super_config - - monkeypatch.setattr( - machine_config_transport, "get_machine_by_atm_pubkey_hex", fake_get_machine - ) - monkeypatch.setattr(machine_config_transport, "get_account", fake_get_account) - monkeypatch.setattr(machine_config_transport, "get_super_config", fake_get_super) - - -_MISSING = object() # sentinel: account with no pubkey - - -def _call(sender: str | None = _ATM_HEX): - req = SimpleNamespace(sender_pubkey=sender) - return asyncio.run(handle_get_machine_config(None, req)) - - -def test_returns_operator_pubkey_and_fee_config(monkeypatch): - _wire(monkeypatch, machine=_machine(), super_config=_super()) - result = _call() - assert result["operator_pubkey"] == _OP_HEX - assert result["fee_config"] is not None - assert result["fee_config"]["cash_in_fee_fraction"] == 0.08 # 0.03 + 0.05 - assert result["fee_config"]["cash_out_fee_fraction"] == 0.08 - assert result["fiat_code"] == "EUR" - assert result["machine_npub"] == _ATM_HEX - assert result["wallet_id"] == "w1" - - -def test_watermark_is_max_of_machine_and_super(monkeypatch): - # machine updated after super → watermark tracks the later one - _wire(monkeypatch, machine=_machine(updated_at=_LATER), super_config=_super()) - assert _call()["created_at"] == int(_LATER.timestamp()) - - -def test_no_super_config_still_returns_operator_pubkey_no_fees(monkeypatch): - # Delivering the operator pubkey re-enables the ATM's fees subscription even - # before the operator has a super-config; fee_config is None until then. - _wire(monkeypatch, machine=_machine(), super_config=None) - result = _call() - assert result["operator_pubkey"] == _OP_HEX - assert result["fee_config"] is None - assert result["created_at"] == int(_NOW.timestamp()) # machine.updated_at only - - -def test_unpaired_sender_raises(monkeypatch): - _wire(monkeypatch, machine=None, super_config=_super()) - with pytest.raises(ValueError, match="not a paired machine"): - _call() - - -def test_operator_without_pubkey_raises(monkeypatch): - _wire(monkeypatch, machine=_machine(), pubkey=_MISSING, super_config=_super()) - with pytest.raises(ValueError, match="no Nostr pubkey"): - _call() - - -def test_missing_sender_pubkey_raises(monkeypatch): - _wire(monkeypatch, machine=_machine(), super_config=_super()) - with pytest.raises(ValueError, match="sender_pubkey"): - _call(sender=None)