feat(transport): get_machine_config RPC — server-delivered machine config (#41)
Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s

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 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 — closing the gap surfaced by bitspire#70. Client half: bitspire#71.

The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.

6 tests; full suite 235 pass; black + ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-02 21:54:40 +02:00
commit 1f5652b425
3 changed files with 226 additions and 0 deletions

View file

@ -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)