From 4cd0041923d7dd2f162bdc94aa00dc1f3b0dc7f0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 10:42:03 +0200 Subject: [PATCH] feat(v2): CRUD + per-direction fee cap validation (#38 2/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new directional fee fields through the write path and adds the 15%-per-direction cap guard at the API boundary. CRUD: - create_machine INSERT includes operator_cash_in_fee_fraction + operator_cash_out_fee_fraction (Pydantic default 0 covers existing callers). - update_machine + update_super_config already use generic update_data dict, so the new fields flow through without per-call changes. API boundary (views_api.py): - _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs candidates against current super-config, rejects if (super_X + operator_X) > 0.15 for either direction. Called from api_create_machine + api_update_machine (with partial-PATCH semantics: unset fields keep the machine's current value). - _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches every active machine; rejects with offending-machine name in the 400 detail if any (effective_super + operator) > cap. Called from api_update_super_config. Cap rounding: float arithmetic rounds (super + operator) to 4 decimals (DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise 0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap. Tests (13 cases, all green): both directions hit the cap, exact-cap acceptance, no-super-config degenerate path, partial PATCH on super-config, offending-machine name in error detail, empty-fleet vacuous safety. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z (cap lock at 15% per direction, defense in depth). Co-Authored-By: Claude Opus 4.7 --- crud.py | 10 +- tests/test_fee_cap_validation.py | 208 +++++++++++++++++++++++++++++++ views_api.py | 126 +++++++++++++++++++ 3 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 tests/test_fee_cap_validation.py diff --git a/crud.py b/crud.py index 1144b0c..e7ca6ea 100644 --- a/crud.py +++ b/crud.py @@ -80,9 +80,13 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach """ INSERT INTO satoshimachine.dca_machines (id, operator_user_id, machine_npub, wallet_id, name, location, - fiat_code, is_active, created_at, updated_at) + fiat_code, is_active, + operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, + created_at, updated_at) VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name, - :location, :fiat_code, :is_active, :created_at, :updated_at) + :location, :fiat_code, :is_active, + :operator_cash_in_fee_fraction, :operator_cash_out_fee_fraction, + :created_at, :updated_at) """, { "id": machine_id, @@ -93,6 +97,8 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach "location": data.location, "fiat_code": data.fiat_code, "is_active": True, + "operator_cash_in_fee_fraction": data.operator_cash_in_fee_fraction, + "operator_cash_out_fee_fraction": data.operator_cash_out_fee_fraction, "created_at": now, "updated_at": now, }, diff --git a/tests/test_fee_cap_validation.py b/tests/test_fee_cap_validation.py new file mode 100644 index 0000000..25b4927 --- /dev/null +++ b/tests/test_fee_cap_validation.py @@ -0,0 +1,208 @@ +""" +Tests for `views_api._assert_machine_fee_cap_safe` and +`_assert_super_config_cap_safe` (aiolabs/satmachineadmin#38, Layer 1). + +Per-direction cap is locked at 15% (super + operator) per coord-log +§2026-06-01T07:22Z. Both helpers enforce the same cap from the +opposite direction: + +- machine_fee_cap_safe runs at machine create/update; pairs candidate + operator fractions against the current super-config +- super_config_cap_safe runs at super-config update; pairs candidate + super fractions against every active machine's operator fractions + and names the first offender so the super-admin can fix the + triggering machine + +Tests monkeypatch the CRUD lookups directly — same shape as +test_collision_guard.py — so the validators are unit-testable without +a live LNbits DB. +""" + +import asyncio +from types import SimpleNamespace + +import pytest + +from .. import views_api +from ..views_api import ( + _assert_machine_fee_cap_safe, + _assert_super_config_cap_safe, +) + + +def _super_config(in_frac: float = 0.0, out_frac: float = 0.0): + """Duck-typed super-config row carrying just the two directional fields + the cap helpers read.""" + return SimpleNamespace( + super_cash_in_fee_fraction=in_frac, + super_cash_out_fee_fraction=out_frac, + ) + + +def _machine( + machine_id: str, + op_in: float, + op_out: float, + name: str | None = None, + npub: str = "a" * 64, +): + return SimpleNamespace( + id=machine_id, + operator_cash_in_fee_fraction=op_in, + operator_cash_out_fee_fraction=op_out, + name=name, + machine_npub=npub, + ) + + +def _patch_super(monkeypatch, value): + async def fake_get(): + return value + + monkeypatch.setattr(views_api, "get_super_config", fake_get) + + +def _patch_machines(monkeypatch, machines: list): + async def fake_list(): + return machines + + monkeypatch.setattr(views_api, "list_all_active_machines", fake_list) + + +# --------------------------------------------------------------------------- +# _assert_machine_fee_cap_safe — candidate operator fractions vs current super +# --------------------------------------------------------------------------- + + +class TestMachineFeeCapSafe: + def test_cash_in_cap_exceeded_raises(self, monkeypatch): + _patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.05)) + # 0.10 + 0.06 = 0.16 > 0.15 → reject + with pytest.raises(Exception) as exc: + asyncio.run(_assert_machine_fee_cap_safe(0.06, 0.05)) + assert exc.value.status_code == 400 + assert "cash-in fee cap exceeded" in exc.value.detail + + def test_cash_out_cap_exceeded_raises(self, monkeypatch): + _patch_super(monkeypatch, _super_config(in_frac=0.05, out_frac=0.10)) + # 0.10 + 0.06 = 0.16 > 0.15 → reject + with pytest.raises(Exception) as exc: + asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.06)) + assert exc.value.status_code == 400 + assert "cash-out fee cap exceeded" in exc.value.detail + + def test_at_exact_cap_passes(self, monkeypatch): + """The cap check is `>`, not `>=` — operators may set exactly + 15% on either direction without rejection.""" + _patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10)) + asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.05)) + + def test_no_super_config_treats_super_as_zero(self, monkeypatch): + """Uninitialised instance (super_config = None) → only operator + counts. Cap then degenerates to a pure operator-fee check.""" + _patch_super(monkeypatch, None) + # 0.14 alone is under cap → pass + asyncio.run(_assert_machine_fee_cap_safe(0.14, 0.14)) + # 0.16 alone exceeds cap → reject + with pytest.raises(Exception) as exc: + asyncio.run(_assert_machine_fee_cap_safe(0.16, 0.05)) + assert exc.value.status_code == 400 + + def test_well_under_cap_passes_silently(self, monkeypatch): + _patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03)) + # Should not raise. + asyncio.run(_assert_machine_fee_cap_safe(0.0333, 0.0777)) + + def test_zero_operator_under_zero_super_passes(self, monkeypatch): + """Free-charge ATM corner case — operator deliberately sets 0 + on both directions, super is 0 on both. Cap of 0 ≤ 0.15.""" + _patch_super(monkeypatch, _super_config(in_frac=0.0, out_frac=0.0)) + asyncio.run(_assert_machine_fee_cap_safe(0.0, 0.0)) + + def test_error_detail_includes_cap_value(self, monkeypatch): + _patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.0)) + with pytest.raises(Exception) as exc: + asyncio.run(_assert_machine_fee_cap_safe(0.10, 0.0)) + # 0.10 + 0.10 = 0.20 > 0.15 + assert "0.15" in exc.value.detail + + +# --------------------------------------------------------------------------- +# _assert_super_config_cap_safe — candidate super fractions vs all machines +# --------------------------------------------------------------------------- + + +class TestSuperConfigCapSafe: + def test_offending_machine_raises_and_is_named(self, monkeypatch): + """When a super-fee bump pushes one machine over the cap, the + rejection names that machine so the super-admin knows which + operator's per-machine config blocks the change.""" + _patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03)) + _patch_machines( + monkeypatch, + [ + _machine("m1", op_in=0.01, op_out=0.02, name="Cafe A"), + _machine("m2", op_in=0.10, op_out=0.02, name="Greedy ATM"), + ], + ) + # New super_in = 0.06. m2 has op_in 0.10 → 0.16 > cap. + with pytest.raises(Exception) as exc: + asyncio.run(_assert_super_config_cap_safe(0.06, None)) + assert exc.value.status_code == 400 + assert "Greedy ATM" in exc.value.detail or "m2" in exc.value.detail + + def test_all_machines_under_cap_passes(self, monkeypatch): + _patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03)) + _patch_machines( + monkeypatch, + [ + _machine("m1", op_in=0.05, op_out=0.05, name="Cafe A"), + _machine("m2", op_in=0.03, op_out=0.03, name="Cafe B"), + ], + ) + # Bump super to 0.08/0.08 → max total = 0.13 + 0.13 = both under cap. + asyncio.run(_assert_super_config_cap_safe(0.08, 0.08)) + + def test_none_direction_pulls_current_value(self, monkeypatch): + """Caller passes new_super_in=None → check uses current super_in + value. Confirms partial-update semantics — caller can change + cash-out alone without retransmitting cash-in.""" + _patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.03)) + _patch_machines(monkeypatch, [_machine("m1", op_in=0.06, op_out=0.0)]) + # Skipping in (None) but op_in=0.06 + current super_in=0.10 = 0.16 > cap. + with pytest.raises(Exception) as exc: + asyncio.run(_assert_super_config_cap_safe(None, 0.05)) + assert exc.value.status_code == 400 + + def test_no_machines_passes(self, monkeypatch): + """Cap check across an empty fleet is vacuously safe.""" + _patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10)) + _patch_machines(monkeypatch, []) + asyncio.run(_assert_super_config_cap_safe(0.12, 0.12)) + + def test_no_super_config_with_machines_uses_zero(self, monkeypatch): + """Uninitialised super + new fractions → cap check still runs + against the candidate new values + each machine's operator + fractions.""" + _patch_super(monkeypatch, None) + _patch_machines( + monkeypatch, + [_machine("m1", op_in=0.10, op_out=0.0, name="Cafe A")], + ) + # 0.06 + 0.10 = 0.16 > cap. + with pytest.raises(Exception) as exc: + asyncio.run(_assert_super_config_cap_safe(0.06, 0.0)) + assert exc.value.status_code == 400 + + def test_uses_machine_id_when_name_missing(self, monkeypatch): + """Machines without a `name` set fall back to the id (or npub + prefix) for the error message — operator-actionable in either + case.""" + _patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03)) + _patch_machines( + monkeypatch, + [_machine("unnamed-machine-id", op_in=0.10, op_out=0.0, name=None)], + ) + with pytest.raises(Exception) as exc: + asyncio.run(_assert_super_config_cap_safe(0.06, None)) + assert "unnamed-machine-id" in exc.value.detail diff --git a/views_api.py b/views_api.py index 6889803..bf51fbd 100644 --- a/views_api.py +++ b/views_api.py @@ -14,6 +14,7 @@ from lnbits.core.models import User from lnbits.decorators import check_super_user, check_user_exists from lnbits.utils.nostr import normalize_public_key +from .calculations import MAX_FEE_FRACTION_PER_DIRECTION from .cassette_transport import ( CassetteTransportError, OperatorIdentityMissing, @@ -48,6 +49,7 @@ from .crud import ( get_settlements_for_operator, get_stuck_settlements_for_operator, get_super_config, + list_all_active_machines, list_cassette_configs_for_machine, lp_is_onboarded, replace_commission_splits, @@ -147,6 +149,105 @@ async def _assert_no_pubkey_collision(machine_npub: str) -> None: ) +async def _assert_machine_fee_cap_safe( + operator_in: float, + operator_out: float, +) -> None: + """Reject create/update if (super_X + operator_X) > 0.15 for either + direction. Locked at 15% per coord-log §2026-06-01T07:22Z; defense in + depth — the bitspire consumer enforces the same cap on the wire-format + side (aiolabs/lamassu-next#57). + + Fetches the current super-config singleton to pair against the + candidate per-machine fractions. NULL super-config (uninitialised + instance) treats super contribution as 0 — the cap then degenerates + to a pure operator-fee check. + """ + super_config = await get_super_config() + super_in = ( + float(super_config.super_cash_in_fee_fraction) if super_config else 0.0 + ) + super_out = ( + float(super_config.super_cash_out_fee_fraction) if super_config else 0.0 + ) + # Fields are stored as DECIMAL(10,4) and Pydantic validators round to + # 4 decimals on the way in, so the source-of-truth precision is 1e-4. + # Round the float-arithmetic sum to that precision before comparison so + # `0.10 + 0.05 = 0.15000000000000002` (IEEE 754) doesn't trip the cap. + total_in = round(super_in + operator_in, 4) + total_out = round(super_out + operator_out, 4) + if total_in > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"cash-in fee cap exceeded: super {super_in:.4f} + operator " + f"{operator_in:.4f} = {total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + if total_out > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"cash-out fee cap exceeded: super {super_out:.4f} + operator " + f"{operator_out:.4f} = {total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + + +async def _assert_super_config_cap_safe( + new_super_in: float | None, + new_super_out: float | None, +) -> None: + """Reject super-config update if any active machine's + (new_super + operator) > 0.15 for either direction. Same cap policy + as _assert_machine_fee_cap_safe but checked across the fleet because + a super update affects every machine. + + `None` for a direction means "no change" — pulls the current value + from super-config so the cap check still runs against the resulting + post-update state. + """ + current = await get_super_config() + effective_in = ( + float(new_super_in) + if new_super_in is not None + else (float(current.super_cash_in_fee_fraction) if current else 0.0) + ) + effective_out = ( + float(new_super_out) + if new_super_out is not None + else (float(current.super_cash_out_fee_fraction) if current else 0.0) + ) + machines = await list_all_active_machines() + for m in machines: + op_in = float(m.operator_cash_in_fee_fraction) + op_out = float(m.operator_cash_out_fee_fraction) + # Round to DECIMAL(10,4) precision — see _assert_machine_fee_cap_safe + # for the IEEE 754 motivation. + total_in = round(effective_in + op_in, 4) + total_out = round(effective_out + op_out, 4) + if total_in > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"super cash-in fee {effective_in:.4f} would exceed cap " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"+ operator {op_in:.4f} = " + f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + if total_out > MAX_FEE_FRACTION_PER_DIRECTION: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + f"super cash-out fee {effective_out:.4f} would exceed cap " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"+ operator {op_out:.4f} = " + f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" + ), + ) + + # ============================================================================= # Machines # ============================================================================= @@ -158,6 +259,10 @@ async def api_create_machine( ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) await _assert_no_pubkey_collision(data.machine_npub) + await _assert_machine_fee_cap_safe( + data.operator_cash_in_fee_fraction, + data.operator_cash_out_fee_fraction, + ) machine = await create_machine(user.id, data) return machine @@ -194,6 +299,23 @@ async def api_update_machine( raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") if data.wallet_id is not None: await _assert_wallet_owned_by(data.wallet_id, user.id) + # Cap check against post-update state — partial PATCH semantics: + # unset directional fields keep the machine's current value. + if ( + data.operator_cash_in_fee_fraction is not None + or data.operator_cash_out_fee_fraction is not None + ): + candidate_in = ( + data.operator_cash_in_fee_fraction + if data.operator_cash_in_fee_fraction is not None + else float(machine.operator_cash_in_fee_fraction) + ) + candidate_out = ( + data.operator_cash_out_fee_fraction + if data.operator_cash_out_fee_fraction is not None + else float(machine.operator_cash_out_fee_fraction) + ) + await _assert_machine_fee_cap_safe(candidate_in, candidate_out) updated = await update_machine(machine_id, data) if updated is None: raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") @@ -808,6 +930,10 @@ async def api_update_super_config( commission, plus the destination wallet for collecting it. The fee is enforced before the operator's own commission_splits ruleset fires (see distribution.process_settlement).""" + await _assert_super_config_cap_safe( + data.super_cash_in_fee_fraction, + data.super_cash_out_fee_fraction, + ) config = await update_super_config(data) if config is None: raise HTTPException(