feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3)

Three trigger points wire fee_transport.publish_fee_config into the
satmachineadmin API endpoints per the #39 spec. All three soft-fail on
transport errors — the underlying CRUD operation (machine create /
update / super-config save) succeeds even when the publish couldn't
reach the relay or the signer, and the operator can re-trigger by
editing again.

views_api.py:
- api_create_machine — publishes always after create, even when
  operator fees default to 0/0 (the resulting super-only payload is
  what unblocks the ATM past its `awaiting-fees` maintenance gate).
  Reads super_config singleton; if absent (m001 should have inserted
  it, so this is an impossible state), skips the publish to avoid
  crashing create.
- api_update_machine — publishes only when either
  operator_cash_*_fee_fraction is in the patch payload. Skip on
  name/location/wallet_id/is_active/fiat_code edits since those don't
  affect the fee model the ATM enforces (avoids unnecessary relay
  churn).
- api_update_super_config — publishes to every active machine when
  either super fraction changes. Per-machine: that machine's
  operator_user_id is the signer (machines owned by different
  operators sign with different keys); each soft-fail is independent.
  Skip if only super_fee_wallet_id changed (no fee-model impact).

Tests (9 cases, all green):
- 3 create-machine triggers: default 0/0 operator fees still publishes
  super-only payload, nonzero operator fees publish full payload,
  None super_config short-circuits without crashing
- 4 update-machine triggers: publishes on cash_in change, publishes on
  cash_out change, skips on name-only, skips on is_active-only
- 2 super-config triggers: publishes per-active-machine signed by
  each machine's operator on fraction change, skips entirely on
  wallet-id-only change (with an assertion that list_all_active_machines
  is never called, proving the short-circuit path)

191/191 tests green. Layer 2 (#39) complete; ready for joint smoke
once bitspire fixes the three deploy gaps from coord-log §2026-06-01T18:30Z
(`relay.aiolabs.dev` default, `VITE_LNBITS_HTTP_URL` dead echo,
operator-fees subscriber not running in maintenance state).

Refs: aiolabs/satmachineadmin#37 (parent), #39 (closes Layer 2),
aiolabs/lamassu-next#57 (Layer 3 consumer — blocked on bitspire-side
gaps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 20:07:56 +02:00
commit 794d7e5395
2 changed files with 424 additions and 0 deletions

View file

@ -0,0 +1,391 @@
"""
Tests for the three views_api trigger points that publish fee config
to ATMs via fee_transport (aiolabs/satmachineadmin#39 Layer 2):
1. api_create_machine publish always after create (so ATM unblocks
past `awaiting-fees` maintenance, even with default 0/0 operator
fees that produce a super-only payload)
2. api_update_machine publish only when either operator fee fraction
changes (skip on name/location/wallet_id/is_active-only edits)
3. api_update_super_config publish to every active machine when
either super fraction changes, signed by each machine's operator
Tests monkeypatch `views_api.publish_fee_config` with a recording stub
to verify the trigger fired (or not) and what arguments it received.
The publisher itself is exercised by test_fee_transport.py these
tests are about the wiring.
"""
import asyncio
from datetime import datetime
from .. import views_api
from ..models import CreateMachineData, Machine, SuperConfig, UpdateMachineData
_NOW = datetime(2026, 6, 1, 12, 0, 0)
_ATM_PUBKEY_HEX = (
"522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
)
_OP_USER_ID = "ac35c9fc842f40f0a0e9809347cd24d1"
def _machine(
machine_id: str = "m1",
npub: str = _ATM_PUBKEY_HEX,
op_in: float = 0.0,
op_out: float = 0.0,
operator_user_id: str = _OP_USER_ID,
) -> Machine:
return Machine(
id=machine_id,
operator_user_id=operator_user_id,
machine_npub=npub,
wallet_id="w1",
name=f"machine-{machine_id}",
location=None,
fiat_code="EUR",
is_active=True,
operator_cash_in_fee_fraction=op_in,
operator_cash_out_fee_fraction=op_out,
created_at=_NOW,
updated_at=_NOW,
)
def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig:
return SuperConfig(
id="default",
super_cash_in_fee_fraction=in_frac,
super_cash_out_fee_fraction=out_frac,
super_fee_wallet_id="super-wallet",
updated_at=_NOW,
)
class _PublishRecorder:
"""Records every (machine.id, super_in, super_out, operator) tuple
publish_fee_config was called with. Drop-in stub for monkeypatching
`views_api.publish_fee_config`."""
def __init__(self):
self.calls: list[tuple[str, float, float, float, float, str]] = []
async def __call__(self, machine, super_config, operator_user_id):
self.calls.append(
(
machine.id,
float(super_config.super_cash_in_fee_fraction),
float(super_config.super_cash_out_fee_fraction),
float(machine.operator_cash_in_fee_fraction),
float(machine.operator_cash_out_fee_fraction),
operator_user_id,
)
)
return {"id": f"evt_{machine.id}", "kind": 30078}
# ---------------------------------------------------------------------------
# Trigger 1: api_create_machine
# ---------------------------------------------------------------------------
class TestCreateMachineTrigger:
def test_publishes_on_create_with_default_operator_fees(self, monkeypatch):
"""Default 0/0 operator fees — payload carries super-only totals.
Publish fires anyway so the ATM gets initial config and can
boot past maintenance."""
recorder = _PublishRecorder()
machine = _machine(op_in=0.0, op_out=0.0)
async def fake_assert_wallet(*args, **kwargs):
return None
async def fake_assert_collision(*args, **kwargs):
return None
async def fake_assert_fee_cap(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return _super()
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", fake_assert_wallet)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_assert_collision)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", fake_assert_fee_cap)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
# Build a CreateMachineData + fake User and invoke the endpoint.
from types import SimpleNamespace
data = CreateMachineData(
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
name="sintra",
)
user = SimpleNamespace(id=_OP_USER_ID)
result = asyncio.run(views_api.api_create_machine(data=data, user=user))
assert result is machine
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.0, 0.0, _OP_USER_ID)
def test_publishes_on_create_with_nonzero_operator_fees(self, monkeypatch):
recorder = _PublishRecorder()
machine = _machine(op_in=0.05, op_out=0.05)
async def passthrough(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return _super(0.03, 0.03)
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
data = CreateMachineData(
machine_npub=_ATM_PUBKEY_HEX,
wallet_id="w1",
operator_cash_in_fee_fraction=0.05,
operator_cash_out_fee_fraction=0.05,
)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(views_api.api_create_machine(data=data, user=user))
assert recorder.calls == [("m1", 0.03, 0.03, 0.05, 0.05, _OP_USER_ID)]
def test_no_super_config_skips_publish(self, monkeypatch):
"""If the super-config singleton is missing (impossible in
practice since m001 inserts it), skip the publish rather than
crash the create. Machine still created."""
recorder = _PublishRecorder()
machine = _machine()
async def passthrough(*args, **kwargs):
return None
async def fake_create_machine(user_id, data):
return machine
async def fake_get_super():
return None
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "create_machine", fake_create_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
data = CreateMachineData(machine_npub=_ATM_PUBKEY_HEX, wallet_id="w1")
user = SimpleNamespace(id=_OP_USER_ID)
result = asyncio.run(views_api.api_create_machine(data=data, user=user))
assert result is machine
assert recorder.calls == []
# ---------------------------------------------------------------------------
# Trigger 2: api_update_machine
# ---------------------------------------------------------------------------
def _wire_update_machine_patches(
monkeypatch, existing_machine, updated_machine, recorder
):
"""Common setup for api_update_machine tests."""
async def passthrough(*args, **kwargs):
return None
async def fake_get_machine(machine_id):
return existing_machine
async def fake_update_machine(machine_id, data):
return updated_machine
async def fake_get_super():
return _super(0.03, 0.03)
monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough)
monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough)
monkeypatch.setattr(views_api, "get_machine", fake_get_machine)
monkeypatch.setattr(views_api, "update_machine", fake_update_machine)
monkeypatch.setattr(views_api, "get_super_config", fake_get_super)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
class TestUpdateMachineTrigger:
def test_publishes_when_operator_cash_in_changes(self, monkeypatch):
recorder = _PublishRecorder()
existing = _machine(op_in=0.05, op_out=0.05)
updated = _machine(op_in=0.07, op_out=0.05)
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(operator_cash_in_fee_fraction=0.07)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.07, 0.05, _OP_USER_ID)
def test_publishes_when_operator_cash_out_changes(self, monkeypatch):
recorder = _PublishRecorder()
existing = _machine(op_in=0.05, op_out=0.05)
updated = _machine(op_in=0.05, op_out=0.08)
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(operator_cash_out_fee_fraction=0.08)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert len(recorder.calls) == 1
assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.05, 0.08, _OP_USER_ID)
def test_no_publish_when_only_name_changes(self, monkeypatch):
"""Name / location / fiat_code / is_active / wallet_id changes
don't affect the fee model the ATM enforces — skip the
republish to avoid relay churn."""
recorder = _PublishRecorder()
existing = _machine()
updated = _machine() # same fees
_wire_update_machine_patches(monkeypatch, existing, updated, recorder)
from types import SimpleNamespace
data = UpdateMachineData(name="new name")
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert recorder.calls == []
def test_no_publish_when_only_is_active_changes(self, monkeypatch):
recorder = _PublishRecorder()
_wire_update_machine_patches(monkeypatch, _machine(), _machine(), recorder)
from types import SimpleNamespace
data = UpdateMachineData(is_active=False)
user = SimpleNamespace(id=_OP_USER_ID)
asyncio.run(
views_api.api_update_machine(machine_id="m1", data=data, user=user)
)
assert recorder.calls == []
# ---------------------------------------------------------------------------
# Trigger 3: api_update_super_config
# ---------------------------------------------------------------------------
class TestSuperConfigUpdateTrigger:
def test_publishes_to_every_active_machine_on_super_fraction_change(
self, monkeypatch
):
"""A super-fee change ripples to every active machine since each
machine's total = super + machine.operator. Republish per-machine
with that machine's operator as the signer (machines owned by
different operators sign with different keys)."""
recorder = _PublishRecorder()
new_super = _super(in_frac=0.04, out_frac=0.04)
machines = [
_machine(machine_id="m1", operator_user_id="op_A"),
_machine(machine_id="m2", operator_user_id="op_B", op_in=0.05, op_out=0.07),
_machine(machine_id="m3", operator_user_id="op_A", op_in=0.02, op_out=0.02),
]
async def fake_assert_cap(*args, **kwargs):
return None
async def fake_update_super(data):
return new_super
async def fake_list_active():
return machines
monkeypatch.setattr(
views_api, "_assert_super_config_cap_safe", fake_assert_cap
)
monkeypatch.setattr(views_api, "update_super_config", fake_update_super)
monkeypatch.setattr(
views_api, "list_all_active_machines", fake_list_active
)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
from ..models import UpdateSuperConfigData
data = UpdateSuperConfigData(super_cash_in_fee_fraction=0.04)
user = SimpleNamespace(id="super_admin")
asyncio.run(views_api.api_update_super_config(data=data, _user=user))
assert len(recorder.calls) == 3
# Verify each call carries the NEW super fractions + that
# machine's operator + own fees
assert recorder.calls[0] == ("m1", 0.04, 0.04, 0.0, 0.0, "op_A")
assert recorder.calls[1] == ("m2", 0.04, 0.04, 0.05, 0.07, "op_B")
assert recorder.calls[2] == ("m3", 0.04, 0.04, 0.02, 0.02, "op_A")
def test_no_publish_when_only_wallet_id_changes(self, monkeypatch):
"""Changing super_fee_wallet_id without touching either fraction
doesn't affect any ATM's fee model skip the fleet-wide
republish."""
recorder = _PublishRecorder()
new_super = _super(in_frac=0.03, out_frac=0.03)
async def fake_assert_cap(*args, **kwargs):
return None
async def fake_update_super(data):
return new_super
async def fake_list_active():
raise AssertionError(
"list_all_active_machines should not be called when "
"no fraction changed"
)
monkeypatch.setattr(
views_api, "_assert_super_config_cap_safe", fake_assert_cap
)
monkeypatch.setattr(views_api, "update_super_config", fake_update_super)
monkeypatch.setattr(
views_api, "list_all_active_machines", fake_list_active
)
monkeypatch.setattr(views_api, "publish_fee_config", recorder)
from types import SimpleNamespace
from ..models import UpdateSuperConfigData
data = UpdateSuperConfigData(super_fee_wallet_id="new-wallet")
user = SimpleNamespace(id="super_admin")
asyncio.run(views_api.api_update_super_config(data=data, _user=user))
assert recorder.calls == []

View file

@ -22,6 +22,7 @@ from .cassette_transport import (
SignerUnavailable, SignerUnavailable,
publish_to_atm, publish_to_atm,
) )
from .fee_transport import publish_fee_config
from .crud import ( from .crud import (
append_settlement_note, append_settlement_note,
count_completed_legs_for_settlement, count_completed_legs_for_settlement,
@ -264,6 +265,12 @@ async def api_create_machine(
data.operator_cash_out_fee_fraction, data.operator_cash_out_fee_fraction,
) )
machine = await create_machine(user.id, data) machine = await create_machine(user.id, data)
# Layer 2 (#39): publish initial fee config to the ATM so it can
# unblock past its `awaiting-fees` maintenance gate. Soft-fails on
# transport errors — machine creation has already succeeded.
super_config = await get_super_config()
if super_config is not None:
await publish_fee_config(machine, super_config, user.id)
return machine return machine
@ -319,6 +326,18 @@ async def api_update_machine(
updated = await update_machine(machine_id, data) updated = await update_machine(machine_id, data)
if updated is None: if updated is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
# Layer 2 (#39): if either operator fee fraction changed, publish a
# fresh kind-30078 to the ATM so it picks up the new total. Skip
# otherwise — name/location/wallet_id/is_active edits don't change
# the fee model the ATM enforces.
fees_changed = (
data.operator_cash_in_fee_fraction is not None
or data.operator_cash_out_fee_fraction is not None
)
if fees_changed:
super_config = await get_super_config()
if super_config is not None:
await publish_fee_config(updated, super_config, user.id)
return updated return updated
@ -939,6 +958,20 @@ async def api_update_super_config(
raise HTTPException( raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config" HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config"
) )
# Layer 2 (#39): a super-fee change ripples to every active machine
# since each machine's total = super + machine.operator. Republish
# per-machine with that machine's operator as the signer.
# Soft-fails per machine independently; partial success is acceptable
# (the operator whose publish failed can re-trigger via a machine
# edit). Skip if neither directional fraction was touched in this
# update (e.g. caller only changed super_fee_wallet_id).
super_fractions_changed = (
data.super_cash_in_fee_fraction is not None
or data.super_cash_out_fee_fraction is not None
)
if super_fractions_changed:
for machine in await list_all_active_machines():
await publish_fee_config(machine, config, machine.operator_user_id)
return config return config