From 794d7e53951c6011a78be5a9d90f3e8c889d5a06 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 20:07:56 +0200 Subject: [PATCH] feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_fee_publish_triggers.py | 391 +++++++++++++++++++++++++++++ views_api.py | 33 +++ 2 files changed, 424 insertions(+) create mode 100644 tests/test_fee_publish_triggers.py diff --git a/tests/test_fee_publish_triggers.py b/tests/test_fee_publish_triggers.py new file mode 100644 index 0000000..650bd73 --- /dev/null +++ b/tests/test_fee_publish_triggers.py @@ -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 == [] diff --git a/views_api.py b/views_api.py index bf51fbd..3c4c6cc 100644 --- a/views_api.py +++ b/views_api.py @@ -22,6 +22,7 @@ from .cassette_transport import ( SignerUnavailable, publish_to_atm, ) +from .fee_transport import publish_fee_config from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -264,6 +265,12 @@ async def api_create_machine( data.operator_cash_out_fee_fraction, ) 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 @@ -319,6 +326,18 @@ async def api_update_machine( updated = await update_machine(machine_id, data) if updated is None: 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 @@ -939,6 +958,20 @@ async def api_update_super_config( raise HTTPException( 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