diff --git a/__init__.py b/__init__.py index d40a411..08ea43a 100644 --- a/__init__.py +++ b/__init__.py @@ -6,6 +6,7 @@ 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 @@ -62,6 +63,11 @@ 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 new file mode 100644 index 0000000..338399a --- /dev/null +++ b/machine_config_transport.py @@ -0,0 +1,103 @@ +""" +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 new file mode 100644 index 0000000..03fa48e --- /dev/null +++ b/tests/test_machine_config_transport.py @@ -0,0 +1,117 @@ +"""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)