feat(v2): CRUD + per-direction fee cap validation (#38 2/5)

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 10:42:03 +02:00
commit 4cd0041923
3 changed files with 342 additions and 2 deletions

10
crud.py
View file

@ -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,
},

View file

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

View file

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