Compare commits
No commits in common. "08eea1ba16dd3fad35efab1acb150427d651060d" and "eefadb6c2036ef14883c6fbe7be13c9465a27130" have entirely different histories.
08eea1ba16
...
eefadb6c20
3 changed files with 0 additions and 226 deletions
|
|
@ -6,7 +6,6 @@ from loguru import logger
|
||||||
|
|
||||||
from .cashin_transport import register_create_withdraw_rpc
|
from .cashin_transport import register_create_withdraw_rpc
|
||||||
from .crud import db
|
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 .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 .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
|
||||||
from .views import spirekeeper_generic_router
|
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
|
# server-side, never client-supplied. Soft-fails if `register_rpc` isn't
|
||||||
# exposed by this lnbits.
|
# exposed by this lnbits.
|
||||||
register_create_withdraw_rpc()
|
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__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -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'")
|
|
||||||
|
|
@ -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)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue