From d87d0db324775bc9e9fb8e2efbfc0df8e2036acc Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 10:18:37 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(v2):=20m009=20+=20models=20=E2=80=94?= =?UTF-8?q?=20split=20fee=20fractions=20by=20direction=20(#38=201/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the schema delta + Pydantic mirror for per-direction fee configuration: - super_config gains super_cash_in_fee_fraction / super_cash_out_fee_fraction (backfilled from the deprecated singleton on migrate-up so live config preserves intent). - dca_machines gains operator_cash_in_fee_fraction / operator_cash_out_fee_fraction (default 0; operator-settable per machine via the upcoming UI). - dca_settlements gains fee_mismatch_sats BIGINT NULL — Phase-1 observability column per coord-log §2026-06-01T07:00Z (lnbits) + option A locked. - MAX_FEE_FRACTION_PER_DIRECTION = 0.15 lives in calculations.py as the single source of truth (defense-in-depth cap, mirrored on the consumer side per aiolabs/lamassu-next#57). Pydantic validators on the new fields keep [0, 1] range checks; the per-direction cap validation lives on the CRUD path in the next commit (needs cross-row context: super-config change must validate against all machines, machine change against current super-config). Closes one step of #38 (Layer 1 of the operator-configurable fee architecture, parent #37). Subsequent commits add CRUD, principal-based split math (fixes the load-bearing super under-payment bug), and the UI surface. Co-Authored-By: Claude Opus 4.7 --- calculations.py | 8 ++++++ migrations.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ models.py | 53 +++++++++++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/calculations.py b/calculations.py index f2beb96..5bfadab 100644 --- a/calculations.py +++ b/calculations.py @@ -16,6 +16,14 @@ What's intentionally NOT here (deleted 2026-05-26): from typing import Dict, Tuple +# Per-direction fee cap (super + operator) for any single direction. +# Locked at 15% per coord-log §2026-06-01T07:22Z (bitspire) — defense in +# depth: producer (this side) refuses to publish/persist > cap; consumer +# (bitspire) refuses to apply > cap. See aiolabs/satmachineadmin#37,#38 +# and aiolabs/lamassu-next#57. +MAX_FEE_FRACTION_PER_DIRECTION = 0.15 + + def calculate_distribution( base_amount_sats: int, client_balances: Dict[str, float], diff --git a/migrations.py b/migrations.py index e1d957c..5e0b3ec 100644 --- a/migrations.py +++ b/migrations.py @@ -644,3 +644,77 @@ async def m008_flip_cassette_configs_pk_to_position(db): await db.execute( "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs" ) + + +async def m009_split_fee_fractions_by_direction(db): + """Split the singleton `super_fee_fraction` into per-direction fields + and add matching per-machine operator fee fractions. Adds the + `fee_mismatch_sats` audit column on settlements. + + Architectural intent (per aiolabs/satmachineadmin#37): + - Super (lnbits administrator) sets X_in% and X_out% — applies + across every machine on the lnbits instance, calculated against + principal. + - Operator (per-machine) sets Y_in% and Y_out% — sits on top of + super, calculated against principal. + - Total fee charged customer = (X+Y)% of principal per direction. + - Distribution: super gets X% of principal; operator gets Y% + (distributed through commission legs as today). + + Fixes the load-bearing bug where the old `super_fee_fraction` was + interpreted as fraction-of-fee, under-paying the super by ~13× per + cashout. The post-migration split math (bitspire.py:parse_settlement + + calculations.py:split_principal_based) is principal-based. + + Schema delta: + - super_config gains super_cash_in_fee_fraction + + super_cash_out_fee_fraction (both backfilled + from the existing super_fee_fraction so live + config preserves intent across migrate-up). + - dca_machines gains operator_cash_in_fee_fraction + + operator_cash_out_fee_fraction (default 0; + operators set via the new UI surface). + - dca_settlements gains fee_mismatch_sats BIGINT NULL — records + bitspire-reported fee minus expected per + satmachineadmin's principal-based recompute. + Phase 1 observability: log + record, never + reject (per coord-log §2026-06-01T07:00Z + lnbits advisory; option A locked). + + Idempotency via column-probe pattern (same shape as m006's rename + sweep). The existing `super_config.super_fee_fraction` column is + NOT dropped here — deprecated, removed in a follow-up release after + callers migrate to the directional fields. + """ + additions = [ + ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("super_config", "super_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_settlements", "fee_mismatch_sats", "BIGINT"), + ] + for table, col, coltype in additions: + try: + await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1") + # column already present — migration partially-ran previously, skip + continue + except Exception: + pass + await db.execute( + f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}" + ) + + # Backfill super-config directional fractions from the legacy singleton + # so the live deployment's super_fee_fraction setting carries forward. + # Guarded WHERE clause: only fire when both new fields are still at + # their DEFAULT 0 (i.e., this is a first migrate-up, not a repeat). + await db.execute( + """ + UPDATE satoshimachine.super_config + SET super_cash_in_fee_fraction = super_fee_fraction, + super_cash_out_fee_fraction = super_fee_fraction + WHERE super_cash_in_fee_fraction = 0 + AND super_cash_out_fee_fraction = 0 + AND super_fee_fraction > 0 + """ + ) diff --git a/models.py b/models.py index 6a18815..1ec2ec4 100644 --- a/models.py +++ b/models.py @@ -21,6 +21,11 @@ class CreateMachineData(BaseModel): for this machine. The same operator can own multiple machines; each machine gets its own wallet so per-machine accounting via Payment.tag (set to "satmachine:{machine_npub}") works natively. + + `operator_cash_*_fee_fraction` is the per-machine operator fee charged on + top of the platform-wide super fee. Both fractions sit on top of the + super's per-direction fractions and are calculated against principal, + not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ machine_npub: str @@ -28,6 +33,16 @@ class CreateMachineData(BaseModel): name: str | None = None location: str | None = None fiat_code: str = "GTQ" + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return 0.0 + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) class Machine(BaseModel): @@ -39,6 +54,8 @@ class Machine(BaseModel): location: str | None fiat_code: str is_active: bool + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 created_at: datetime updated_at: datetime @@ -49,6 +66,16 @@ class UpdateMachineData(BaseModel): fiat_code: str | None = None is_active: bool | None = None wallet_id: str | None = None + operator_cash_in_fee_fraction: float | None = None + operator_cash_out_fee_fraction: float | None = None + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return v + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) # ============================================================================= @@ -220,6 +247,12 @@ class CreateDcaSettlementData(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str # 'cash_out' | 'cash_in' + # Phase-1 observability column (aiolabs/satmachineadmin#38). + # `bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)` — + # positive means bitspire over-reported, negative means under-reported. + # Recorded unconditionally; WARN-logged when |delta| > tolerance. NULL + # only on pre-#38 rows. + fee_mismatch_sats: int | None = None bills_json: str | None = None cassettes_json: str | None = None @@ -239,6 +272,7 @@ class DcaSettlement(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str + fee_mismatch_sats: int | None = None bills_json: str | None cassettes_json: str | None # 'pending' (default at insert) @@ -415,21 +449,34 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str + # Deprecated singleton fee fraction — retained for one release while + # callers migrate to the per-direction fields below. The new math + # (bitspire.py:parse_settlement) only reads the directional fields. super_fee_fraction: float + super_cash_in_fee_fraction: float = 0.0 + super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None updated_at: datetime class UpdateSuperConfigData(BaseModel): + # Deprecated; setting either directional field is the supported path. + # Writes here continue to apply for one release for migration safety. super_fee_fraction: float | None = None + super_cash_in_fee_fraction: float | None = None + super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None - @validator("super_fee_fraction") - def fee_in_unit_range(cls, v): + @validator( + "super_fee_fraction", + "super_cash_in_fee_fraction", + "super_cash_out_fee_fraction", + ) + def _fee_in_unit_range(cls, v): if v is None: return v if v < 0 or v > 1: - raise ValueError("super_fee_fraction must be between 0 and 1") + raise ValueError("super fee fraction must be between 0 and 1") return round(float(v), 4) -- 2.53.0 From 4cd0041923d7dd2f162bdc94aa00dc1f3b0dc7f0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 10:42:03 +0200 Subject: [PATCH 2/5] 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( -- 2.53.0 From 1babdfbf066e3d4ceb4634353bc79574f5b582e7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 11:24:09 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(v2):=20principal-based=20fee=20split?= =?UTF-8?q?=20=E2=80=94=20fixes=20super=20under-payment=20(#38=203/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the broken fraction-of-fee math with fraction-of-principal, direction-aware. Pre-#38: super_fee_fraction was interpreted as `round(fee_sats * super_fraction)`, paying super ~13× below intent on every cashout since the bitspire wire-shape landed. Post-#38: super and operator shares are computed independently against principal using the per-direction fractions from SuperConfig + Machine. Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code" (v2-bitspire hasn't shipped to users), no compat shims: - calculations.py: delete `split_two_stage_commission` (legacy fraction-of-fee). Keep `split_principal_based` as the sole split fn. - migrations.py m009: extend to also DROP the deprecated `super_fee_fraction` column after backfilling its value into the new directional fields. - models.py: drop `super_fee_fraction` from SuperConfig + UpdateSuperConfigData entirely. - bitspire.py parse_settlement: new signature takes `super_config: SuperConfig` instead of `super_fee_fraction: float`. Resolves directional fractions from super_config + machine by tx_type, then computes via split_principal_based. Raises SettlementInvariantError on unknown tx_type. - tasks.py: pass `super_config` through to parse_settlement; assert non-None (m001 inserts the singleton at install time — None is an impossible state). - partial-dispense ratio path in distribution.py is unchanged — still uses `settlement.platform_fee_sats / settlement.fee_sats` from the landed row, which is the right invariant (lock at landing) and independent of the per-direction config. Tests: - Rename `test_two_stage_split.py` → `test_operator_split_legs.py`. Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs (still-production fn) and TestPartialDispenseSplitRatio (inline ratio math in distribution.py). - New `test_principal_based_fees.py`: pure-math tests for `split_principal_based` (six cases including a direct regression test pinning the pre-#38 bug at 240→3000 sats per 100k principal at 3% super), plus parse_settlement directional dispatch tests (cash-in routes through cash-in fractions; cash-out through cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross- direction guard). Migration verified end-to-end via container restart: super_config columns post-m009 = id/super_fee_wallet_id/updated_at/ super_cash_in_fee_fraction/super_cash_out_fee_fraction (no super_fee_fraction). dca_machines + dca_settlements gained the expected new columns. 156/156 tests green. Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes the load-bearing super under-payment bug standalone. Co-Authored-By: Claude Opus 4.7 --- bitspire.py | 39 +++-- calculations.py | 61 ++++--- migrations.py | 51 ++++-- models.py | 8 - tasks.py | 4 +- tests/test_operator_split_legs.py | 150 ++++++++++++++++ tests/test_principal_based_fees.py | 270 +++++++++++++++++++++++++++++ tests/test_two_stage_split.py | 214 ----------------------- 8 files changed, 522 insertions(+), 275 deletions(-) create mode 100644 tests/test_operator_split_legs.py create mode 100644 tests/test_principal_based_fees.py delete mode 100644 tests/test_two_stage_split.py diff --git a/bitspire.py b/bitspire.py index e40c230..cbf6c67 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,7 +17,8 @@ from __future__ import annotations import json from typing import Any, Optional -from .models import CreateDcaSettlementData, Machine +from .calculations import split_principal_based +from .models import CreateDcaSettlementData, Machine, SuperConfig # Sentinel value bitSpire sets in Payment.extra.source so we know an inbound # payment originated from an ATM cash-out and not some other extension or @@ -219,23 +220,30 @@ def parse_settlement( payment_hash: str, wire_sats: int, extra: dict, - super_fee_fraction: float, + super_config: SuperConfig, ) -> CreateDcaSettlementData: """Build a CreateDcaSettlementData for an inbound payment landing on `machine`'s wallet. + Splits the fee on a principal-based, direction-aware model + (aiolabs/satmachineadmin#37,#38): + + platform_fee_sats = round(principal_sats * super_cash_{type}_fee_fraction) + operator_fee_sats = round(principal_sats * operator_cash_{type}_fee_fraction) + + where the directional super fraction comes from `super_config` and + the operator fraction comes from `machine`. The bitspire-reported + `fee_sats` field is preserved on the settlement as the customer's + actual paid total, but is NOT used as input to the split. + Requires bitSpire's canonical Payment.extra stamp (source="bitspire" plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises `SettlementMetadataError` on missing/partial stamp — caller records the settlement as 'rejected' for upstream investigation. Raises `SettlementInvariantError` if the stamped values violate the canonical sat-amount invariants (range + sum, see - `_assert_sat_invariants`). + `_assert_sat_invariants`) or `tx_type` is unknown. """ - if not (0.0 <= super_fee_fraction <= 1.0): - raise SettlementInvariantError( - f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" - ) if not is_bitspire_payment(extra): raise SettlementMetadataError( f"Payment.extra missing `source: \"bitspire\"` marker on machine " @@ -253,8 +261,20 @@ def parse_settlement( f"(lamassu-next#44) requires both. Investigate the ATM " f"firmware on machine {machine.machine_npub[:12]}..." ) - platform_fee_sats = round(fee_sats * super_fee_fraction) - operator_fee_sats = fee_sats - platform_fee_sats + tx_type = _coerce_str(extra.get("type")) or "cash_out" + if tx_type == "cash_in": + super_frac = float(super_config.super_cash_in_fee_fraction) + operator_frac = float(machine.operator_cash_in_fee_fraction) + elif tx_type == "cash_out": + super_frac = float(super_config.super_cash_out_fee_fraction) + operator_frac = float(machine.operator_cash_out_fee_fraction) + else: + raise SettlementInvariantError( + f"unknown tx_type={tx_type!r}; expected 'cash_in' or 'cash_out'" + ) + platform_fee_sats, operator_fee_sats = split_principal_based( + principal_sats, super_frac, operator_frac + ) exchange_rate = _coerce_float(extra.get("exchange_rate")) if exchange_rate is None or exchange_rate <= 0: # Without exchange rate we can't compute fiat. Use 1.0 as a stand-in @@ -268,7 +288,6 @@ def parse_settlement( # in BTC today, but the cash side has its own ground truth). fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0 fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code - tx_type = _coerce_str(extra.get("type")) or "cash_out" data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment_hash, diff --git a/calculations.py b/calculations.py index 5bfadab..c76600b 100644 --- a/calculations.py +++ b/calculations.py @@ -106,36 +106,49 @@ def calculate_distribution( return distributions -def split_two_stage_commission( - fee_sats: int, super_fee_fraction: float +def split_principal_based( + principal_sats: int, + super_frac: float, + operator_frac: float, ) -> Tuple[int, int]: - """Stage-1 of the v2 commission split: super takes `super_fee_fraction` - of the total fee; the remainder is what the operator's own ruleset - acts on. + """Compute platform + operator fee shares as independent fractions of + `principal_sats`. Both shares are derived from the customer's + principal (the canonical source of truth), NOT back-derived from + `fee_sats`. - Returns (platform_fee_sats, operator_fee_sats). Platform is rounded; - operator absorbs the rounding remainder so platform_fee + operator_fee - == fee_sats exactly. + Returns (platform_fee_sats, operator_fee_sats). Both are rounded + independently; rounding remainders do NOT compound — the customer + pays whatever bitspire collected, and any drift between (super + + operator) and the bitspire-reported `fee_sats` surfaces via + `dca_settlements.fee_mismatch_sats`. Examples: - >>> split_two_stage_commission(100, 0.30) - (30, 70) - >>> split_two_stage_commission(7965, 0.30) - (2390, 5575) - >>> split_two_stage_commission(100, 0.0) - (0, 100) - >>> split_two_stage_commission(100, 1.0) - (100, 0) + >>> split_principal_based(100_000, 0.03, 0.05) + (3000, 5000) + >>> split_principal_based(266_800, 0.03, 0.0) + (8004, 0) + >>> split_principal_based(100_000, 0.0, 0.0) + (0, 0) + >>> split_principal_based(100_000, 0.15, 0.0) + (15000, 0) + + The pre-#38 bug this corrects: the old math interpreted the super + fee as `fraction_of_fee` rather than `fraction_of_principal`. On a + 100_000-sat principal with an 8% total bitspire fee (= 8_000 sats + fee_sats) and super_fraction=0.03, the bug paid the super + `round(8_000 * 0.03) = 240` sats — ~13× below the intended + `100_000 * 0.03 = 3_000` sats per-settlement. Repeated on every + cash-out since the bitspire wire-shape landed. See + aiolabs/satmachineadmin#37 (parent) + #38 (this layer). """ - if not (0.0 <= super_fee_fraction <= 1.0): - raise ValueError( - f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" - ) - if fee_sats <= 0: + if not (0.0 <= super_frac <= 1.0): + raise ValueError(f"super_frac must be in [0, 1], got {super_frac}") + if not (0.0 <= operator_frac <= 1.0): + raise ValueError(f"operator_frac must be in [0, 1], got {operator_frac}") + if principal_sats <= 0: return 0, 0 - platform = round(fee_sats * super_fee_fraction) - platform = max(0, min(platform, fee_sats)) - operator = fee_sats - platform + platform = max(0, round(principal_sats * super_frac)) + operator = max(0, round(principal_sats * operator_frac)) return platform, operator diff --git a/migrations.py b/migrations.py index 5e0b3ec..97e4a68 100644 --- a/migrations.py +++ b/migrations.py @@ -682,9 +682,11 @@ async def m009_split_fee_fractions_by_direction(db): lnbits advisory; option A locked). Idempotency via column-probe pattern (same shape as m006's rename - sweep). The existing `super_config.super_fee_fraction` column is - NOT dropped here — deprecated, removed in a follow-up release after - callers migrate to the directional fields. + sweep). The deprecated `super_config.super_fee_fraction` singleton + is backfilled into the new directional fields, then dropped in the + same migration — strict-from-the-start per workspace CLAUDE.md + "Backwards-compatibility on pre-public-launch code" (v2-bitspire + hasn't shipped to public users). """ additions = [ ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), @@ -704,17 +706,32 @@ async def m009_split_fee_fractions_by_direction(db): f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}" ) - # Backfill super-config directional fractions from the legacy singleton - # so the live deployment's super_fee_fraction setting carries forward. - # Guarded WHERE clause: only fire when both new fields are still at - # their DEFAULT 0 (i.e., this is a first migrate-up, not a repeat). - await db.execute( - """ - UPDATE satoshimachine.super_config - SET super_cash_in_fee_fraction = super_fee_fraction, - super_cash_out_fee_fraction = super_fee_fraction - WHERE super_cash_in_fee_fraction = 0 - AND super_cash_out_fee_fraction = 0 - AND super_fee_fraction > 0 - """ - ) + # Backfill + drop the legacy singleton, gated on the column still + # existing. Once dropped, a re-run of this migration skips both + # steps cleanly. + try: + await db.fetchone( + "SELECT super_fee_fraction FROM satoshimachine.super_config LIMIT 1" + ) + legacy_present = True + except Exception: + legacy_present = False + + if legacy_present: + # Carry the live deployment's super_fee_fraction setting forward + # into both directional fields, but only when the operator hasn't + # already explicitly set per-direction values (i.e., both are + # still at DEFAULT 0). + await db.execute( + """ + UPDATE satoshimachine.super_config + SET super_cash_in_fee_fraction = super_fee_fraction, + super_cash_out_fee_fraction = super_fee_fraction + WHERE super_cash_in_fee_fraction = 0 + AND super_cash_out_fee_fraction = 0 + AND super_fee_fraction > 0 + """ + ) + await db.execute( + "ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction" + ) diff --git a/models.py b/models.py index 1ec2ec4..1094f6a 100644 --- a/models.py +++ b/models.py @@ -449,10 +449,6 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str - # Deprecated singleton fee fraction — retained for one release while - # callers migrate to the per-direction fields below. The new math - # (bitspire.py:parse_settlement) only reads the directional fields. - super_fee_fraction: float super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None @@ -460,15 +456,11 @@ class SuperConfig(BaseModel): class UpdateSuperConfigData(BaseModel): - # Deprecated; setting either directional field is the supported path. - # Writes here continue to apply for one release for migration safety. - super_fee_fraction: float | None = None super_cash_in_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None @validator( - "super_fee_fraction", "super_cash_in_fee_fraction", "super_cash_out_fee_fraction", ) diff --git a/tasks.py b/tasks.py index 7f2a276..9778a20 100644 --- a/tasks.py +++ b/tasks.py @@ -125,14 +125,14 @@ async def _handle_payment(payment: Payment) -> None: # stamp is missing, SettlementInvariantError on any range/sum # breach. super_config = await get_super_config() - super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0 + assert super_config is not None # m001 inserts the default singleton try: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, wire_sats=payment.sat, extra=extra, - super_fee_fraction=super_fee_fraction, + super_config=super_config, ) except (SettlementMetadataError, SettlementInvariantError) as exc: await _record_rejected(payment, machine, exc) diff --git a/tests/test_operator_split_legs.py b/tests/test_operator_split_legs.py new file mode 100644 index 0000000..1e0bbca --- /dev/null +++ b/tests/test_operator_split_legs.py @@ -0,0 +1,150 @@ +""" +Tests for `allocate_operator_split_legs` (operator's commission-leg +distribution) and the partial-dispense ratio math in +`apply_partial_dispense_and_redistribute`. + +Both are split-arithmetic concerns that survive the post-#38 +principal-based-math refactor: + + - `allocate_operator_split_legs` slices the operator's share across + their commission legs by their per-leg fractions. Function-level, + no fee-model coupling. + - Partial-dispense ratio math (in distribution.py) preserves the + ORIGINAL platform/operator ratio recorded against a settlement at + land time when an operator partial-dispenses post-hoc. The ratio + comes from the absolute platform_fee_sats / fee_sats recorded on + the settlement row, NOT the current super-config fractions — the + contract is locked at landing. + +Pre-#38 tests for `split_two_stage_commission` lived here; that +function was removed when the principal-based math landed +(aiolabs/satmachineadmin#38). +""" + +import pytest + +from ..calculations import allocate_operator_split_legs + + +class TestAllocateOperatorSplitLegs: + """Operator's remaining share split into commission legs by fraction.""" + + def test_plan_example_50_30_20_on_70(self): + amounts = allocate_operator_split_legs(70, [0.5, 0.3, 0.2]) + assert amounts == [35, 21, 14] + + def test_realistic_50_30_20_on_5575(self): + amounts = allocate_operator_split_legs(5575, [0.5, 0.3, 0.2]) + # Plan-scale: 5575 * (0.5, 0.3, 0.2) = (2787.5, 1672.5, 1115) + # Last leg absorbs rounding remainders so sum == 5575 exactly. + assert sum(amounts) == 5575 + assert amounts[0] == round(5575 * 0.5) + assert amounts[1] == round(5575 * 0.3) + # Last leg absorbs the remainder. + assert amounts[2] == 5575 - amounts[0] - amounts[1] + + def test_single_leg_full_remainder(self): + amounts = allocate_operator_split_legs(7965, [1.0]) + assert amounts == [7965] + + def test_zero_operator_fee_zeros_all_legs(self): + amounts = allocate_operator_split_legs(0, [0.5, 0.3, 0.2]) + assert amounts == [0, 0, 0] + + def test_empty_legs_list_returns_empty(self): + amounts = allocate_operator_split_legs(100, []) + assert amounts == [] + + def test_last_leg_absorbs_rounding_remainder(self): + # 100 sats split [1/3, 1/3, 1/3] — last leg absorbs the +1 remainder. + amounts = allocate_operator_split_legs(100, [1 / 3, 1 / 3, 1 / 3]) + assert sum(amounts) == 100 + assert amounts[0] == round(100 / 3) # 33 + assert amounts[1] == round(100 / 3) # 33 + # Last leg absorbs the rounding (34, not 33) so total == 100. + assert amounts[2] == 100 - amounts[0] - amounts[1] + + @pytest.mark.parametrize( + "operator_fee,fractions", + [ + (1, [0.5, 0.5]), + (7, [0.5, 0.3, 0.2]), + (100, [0.5, 0.5]), + (5575, [0.5, 0.3, 0.2]), + (1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]), + ], + ) + def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions): + amounts = allocate_operator_split_legs(operator_fee, fractions) + assert sum(amounts) == operator_fee + assert all(a >= 0 for a in amounts) + + +class TestPartialDispenseSplitRatio: + """Partial-dispense recompute (closes #11 H6) must preserve the + ORIGINAL platform/operator ratio recorded on the settlement row at + land time. Super raising or lowering a global rate post-hoc must + NOT retroactively change an existing settlement's share split. + + The math is inlined in `apply_partial_dispense_and_redistribute` + (distribution.py) rather than in a standalone function. These tests + mirror the inline math so a future refactor doesn't silently change + the invariant. + """ + + def _recompute(self, original_fee, original_platform_fee, new_fee): + """Mirror of the ratio math in apply_partial_dispense_and_redistribute.""" + if original_fee > 0: + ratio = original_platform_fee / original_fee + else: + ratio = 0.0 + new_platform = round(new_fee * ratio) + new_platform = max(0, min(new_platform, new_fee)) + new_operator = new_fee - new_platform + return new_platform, new_operator + + def test_30pct_lands_then_partial(self): + # Landed at platform ratio 30/100 = 0.30; new fee = 50. + # Original ratio preserved → new_platform = round(50 * 0.30) = 15. + new_platform, new_operator = self._recompute(100, 30, 50) + assert new_platform == 15 + assert new_operator == 35 + assert new_platform + new_operator == 50 + + def test_super_changed_rate_doesnt_affect_existing_settlement(self): + # Landed with platform=2390, fee=7965 (ratio ≈ 0.30). Super then + # bumps the global rate to 50%. Operator partial-dispenses to + # 50% gross → new_fee = round(7965 * 0.5) = 3982. The 30% ratio + # at land time MUST persist regardless of the new super rate. + new_platform, new_operator = self._recompute(7965, 2390, 3982) + # Expected with original ratio: round(3982 * 0.30006...) = 1195 + # With (broken) current rate of 50%: would be 1991 — much higher. + assert 1190 <= new_platform <= 1200 + assert new_platform + new_operator == 3982 + # Original platform share was ~30%; preserved within rounding. + assert abs(new_platform / 3982 - 2390 / 7965) < 0.001 + + def test_zero_original_fee_yields_zero_platform(self): + new_platform, new_operator = self._recompute(0, 0, 0) + assert new_platform == 0 + assert new_operator == 0 + + def test_invariant_sum_equals_new_fee(self): + # Random-ish parameter sweep over realistic values. + cases = [ + (100, 30, 50), + (100, 0, 50), # original platform_fee was 0 + (100, 100, 50), # original platform_fee was full fee + (7965, 2390, 3982), + (7965, 7965, 3982), + (1_000_000, 333_333, 250_000), + ] + for orig_comm, orig_plat, new_comm in cases: + new_platform, new_operator = self._recompute( + orig_comm, orig_plat, new_comm + ) + assert new_platform + new_operator == new_comm, ( + f"sum invariant violated: {orig_comm=} {orig_plat=} " + f"{new_comm=} → {new_platform=} {new_operator=}" + ) + assert 0 <= new_platform <= new_comm diff --git a/tests/test_principal_based_fees.py b/tests/test_principal_based_fees.py new file mode 100644 index 0000000..ebdd040 --- /dev/null +++ b/tests/test_principal_based_fees.py @@ -0,0 +1,270 @@ +""" +Tests for the post-#38 principal-based fee split: + + - `calculations.split_principal_based(principal_sats, super_frac, + operator_frac)` — pure-function math + - `bitspire.parse_settlement` — directional dispatch by tx_type + ("cash_in" → super_cash_in + operator_cash_in; + "cash_out" → super_cash_out + operator_cash_out) + +The bug this layer closes: pre-#38 math interpreted super_fee_fraction +as fraction-of-fee instead of fraction-of-principal, under-paying the +super by ~13× per cashout. Tests below pin the new math to the +intended fraction-of-principal model and verify the per-direction +routing through parse_settlement. + +Fee mismatch recording (`fee_mismatch_sats` column, Phase 1 +observability per coord-log §2026-06-01T07:00Z) lands in the next +commit; those tests live in `test_fee_mismatch_recording.py`. +""" + +from datetime import datetime + +import pytest + +from ..bitspire import SettlementInvariantError, parse_settlement +from ..calculations import split_principal_based +from ..models import Machine, SuperConfig + + +# --------------------------------------------------------------------------- +# split_principal_based — pure-function math +# --------------------------------------------------------------------------- + + +class TestSplitPrincipalBased: + def test_super_fraction_only(self): + """Operator at 0% — super takes exactly super_frac of principal, + operator gets 0.""" + platform, operator = split_principal_based(100_000, 0.03, 0.0) + assert platform == 3_000 + assert operator == 0 + + def test_operator_fraction_only(self): + """Super at 0% — operator takes exactly operator_frac of + principal, platform gets 0.""" + platform, operator = split_principal_based(100_000, 0.0, 0.05) + assert platform == 0 + assert operator == 5_000 + + def test_both_fractions(self): + """Both shares independently computed against principal — total + is super + operator, not anchored to any fee_sats input.""" + platform, operator = split_principal_based(100_000, 0.03, 0.05) + assert platform == 3_000 + assert operator == 5_000 + + def test_zero_principal_yields_zero_shares(self): + platform, operator = split_principal_based(0, 0.03, 0.05) + assert platform == 0 + assert operator == 0 + + def test_negative_principal_yields_zero_shares(self): + """Defensive: negative principal can't happen in production but + the function should not produce negative outputs if it ever does.""" + platform, operator = split_principal_based(-100, 0.03, 0.05) + assert platform == 0 + assert operator == 0 + + def test_rounding_does_not_compound(self): + """The two shares round independently — there is no carryover. + On a 1_000_000-sat principal with super=0.0333, operator=0.0777, + each share rounds against principal individually.""" + platform, operator = split_principal_based(1_000_000, 0.0333, 0.0777) + assert platform == round(1_000_000 * 0.0333) # 33_300 + assert operator == round(1_000_000 * 0.0777) # 77_700 + + def test_super_frac_out_of_range_raises(self): + with pytest.raises(ValueError, match="super_frac"): + split_principal_based(100_000, 1.5, 0.0) + with pytest.raises(ValueError, match="super_frac"): + split_principal_based(100_000, -0.1, 0.0) + + def test_operator_frac_out_of_range_raises(self): + with pytest.raises(ValueError, match="operator_frac"): + split_principal_based(100_000, 0.0, 1.5) + with pytest.raises(ValueError, match="operator_frac"): + split_principal_based(100_000, 0.0, -0.1) + + def test_super_under_payment_bug_regression(self): + """Direct regression test for the bug this layer closes. + + Pre-#38 math (deleted): `round(fee_sats * super_fraction)` with + fee_sats=8_000 (= 8% of 100_000 principal) and super_fraction=0.03 + produced platform_fee_sats=240 — ~13× below intent. + + Post-#38 math: split_principal_based(100_000, 0.03, 0.05) gives + platform=3_000, which IS the intended 3% of principal.""" + platform, operator = split_principal_based(100_000, 0.03, 0.05) + # Post-#38: super gets intended 3% of principal (3_000 sats) + # Pre-#38 would have produced ~240 sats from round(8000 * 0.03). + assert platform == 3_000 + + +# --------------------------------------------------------------------------- +# parse_settlement — directional dispatch via tx_type +# --------------------------------------------------------------------------- + + +def _bitspire_extra( + *, + tx_type: str = "cash_out", + principal_sats: int = 100_000, + fee_sats: int = 8_000, + exchange_rate: float = 0.00001, + fiat_amount: float = 100.0, + currency: str = "EUR", + nostr_sender_pubkey: str = "a" * 64, + extra_overrides: dict | None = None, +): + """Canonical bitspire-stamped Payment.extra dict for tests. Mirrors + the shape required by `is_bitspire_payment` + the canonical sat- + amount invariants in `_assert_sat_invariants`.""" + base = { + "source": "bitspire", + "type": tx_type, + "principal_sats": principal_sats, + "fee_sats": fee_sats, + "fee_fraction": fee_sats / principal_sats if principal_sats else 0.0, + "exchange_rate": exchange_rate, + "fiat_amount": fiat_amount, + "currency": currency, + "txid": "fake-txid", + "nostr_sender_pubkey": nostr_sender_pubkey, + } + if extra_overrides: + base.update(extra_overrides) + return base + + +_NOW = datetime(2026, 6, 1, 12, 0, 0) + + +def _machine( + machine_id: str = "m1", + machine_npub: str = "a" * 64, + op_in: float = 0.0, + op_out: float = 0.0, + fiat_code: str = "EUR", +) -> Machine: + return Machine( + id=machine_id, + operator_user_id="op1", + machine_npub=machine_npub, + wallet_id="w1", + name="Test", + location=None, + fiat_code=fiat_code, + 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_config(in_frac: float = 0.0, out_frac: float = 0.0) -> 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 TestParseSettlementDirectional: + def test_cash_out_uses_cash_out_fractions(self): + """tx_type='cash_out' must route to super_cash_out + + operator_cash_out fractions.""" + machine = _machine(op_in=0.10, op_out=0.05) + super_cfg = _super_config(in_frac=0.10, out_frac=0.03) + extra = _bitspire_extra(tx_type="cash_out", principal_sats=100_000) + + data = parse_settlement( + machine=machine, + payment_hash="ph1", + wire_sats=108_000, + extra=extra, + super_config=super_cfg, + ) + # super_cash_out=0.03, operator_cash_out=0.05 against 100_000 principal + assert data.platform_fee_sats == 3_000 + assert data.operator_fee_sats == 5_000 + assert data.tx_type == "cash_out" + + def test_cash_in_uses_cash_in_fractions(self): + """tx_type='cash_in' must route to super_cash_in + + operator_cash_in fractions (not cash_out).""" + machine = _machine(op_in=0.04, op_out=0.10) + super_cfg = _super_config(in_frac=0.02, out_frac=0.10) + extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000) + + # cash-in wire invariant: wire = principal - fee + data = parse_settlement( + machine=machine, + payment_hash="ph2", + wire_sats=92_000, + extra=extra, + super_config=super_cfg, + ) + # super_cash_in=0.02, operator_cash_in=0.04 against 100_000 principal + assert data.platform_fee_sats == 2_000 + assert data.operator_fee_sats == 4_000 + assert data.tx_type == "cash_in" + + def test_unknown_tx_type_raises(self): + machine = _machine() + super_cfg = _super_config() + extra = _bitspire_extra( + tx_type="cash_out", + extra_overrides={"type": "withdrawal"}, # not a known direction + ) + with pytest.raises(SettlementInvariantError, match="unknown tx_type"): + parse_settlement( + machine=machine, + payment_hash="ph3", + wire_sats=108_000, + extra=extra, + super_config=super_cfg, + ) + + def test_zero_fractions_zero_split(self): + """Free-charge ATM: both super + operator at 0 → platform and + operator fees are both 0, principal is the full take.""" + machine = _machine(op_in=0.0, op_out=0.0) + super_cfg = _super_config(in_frac=0.0, out_frac=0.0) + extra = _bitspire_extra( + tx_type="cash_out", principal_sats=100_000, fee_sats=0 + ) + + data = parse_settlement( + machine=machine, + payment_hash="ph4", + wire_sats=100_000, + extra=extra, + super_config=super_cfg, + ) + assert data.platform_fee_sats == 0 + assert data.operator_fee_sats == 0 + assert data.principal_sats == 100_000 + + def test_cash_in_does_not_use_cash_out_config(self): + """Cross-direction guard: cash-in must NOT pick up cash-out's + super or operator fractions even when they're set differently. + Pin both directions concretely to prove the dispatch.""" + machine = _machine(op_in=0.01, op_out=0.10) + super_cfg = _super_config(in_frac=0.01, out_frac=0.10) + extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000) + + # cash-in wire invariant: wire = principal - fee + data = parse_settlement( + machine=machine, + payment_hash="ph5", + wire_sats=92_000, + extra=extra, + super_config=super_cfg, + ) + # Cash-in totals = 0.01 + 0.01 = 0.02; not 0.10 + 0.10 = 0.20 + assert data.platform_fee_sats == 1_000 # 100_000 * 0.01 + assert data.operator_fee_sats == 1_000 # 100_000 * 0.01 diff --git a/tests/test_two_stage_split.py b/tests/test_two_stage_split.py deleted file mode 100644 index d48cb07..0000000 --- a/tests/test_two_stage_split.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -Tests for the v2 two-stage commission split (super first, operator remainder). - -The plan calls out a verification scenario explicitly: - super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a - 100-sat fee → super_wallet gets 30, operator legs get 35 / 21 / 14. - -Also covers the edge cases: super_fee_fraction=0.0 (no super takes the -whole fee), super_fee_fraction=1.0 (super takes everything), single-leg -operator ruleset, zero operator fee. -""" - -import pytest - -from ..calculations import ( - allocate_operator_split_legs, - split_two_stage_commission, -) - - -class TestSplitTwoStageCommission: - """Stage-1: super takes super_fee_fraction of the fee; operator gets rest.""" - - def test_plan_example_100sats_30pct(self): - platform, operator = split_two_stage_commission(100, 0.30) - assert platform == 30 - assert operator == 70 - assert platform + operator == 100 - - def test_realistic_7965sats_30pct(self): - # From the plan's 2000 GTQ → 266800 sats @ 3% commission example. - platform, operator = split_two_stage_commission(7965, 0.30) - assert platform == 2390 # round(7965 * 0.30) = 2389.5 → 2390 - assert operator == 5575 # 7965 - 2390 - assert platform + operator == 7965 - - def test_super_fraction_zero_leaves_all_to_operator(self): - platform, operator = split_two_stage_commission(7965, 0.0) - assert platform == 0 - assert operator == 7965 - - def test_super_fraction_one_takes_everything(self): - platform, operator = split_two_stage_commission(7965, 1.0) - assert platform == 7965 - assert operator == 0 - - def test_zero_commission(self): - platform, operator = split_two_stage_commission(0, 0.30) - assert platform == 0 - assert operator == 0 - - def test_negative_commission_clamps_to_zero(self): - # Defensive: should never happen, but verify we don't go negative. - platform, operator = split_two_stage_commission(-100, 0.30) - assert platform == 0 - assert operator == 0 - - @pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000]) - @pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0]) - def test_invariant_sum_equals_commission(self, fee_sats, super_fraction): - platform, operator = split_two_stage_commission(fee_sats, super_fraction) - assert platform + operator == fee_sats - assert 0 <= platform <= fee_sats - assert 0 <= operator <= fee_sats - - -class TestAllocateOperatorSplitLegs: - """Stage-2: operator's remainder split across N leg wallets per pct rules.""" - - def test_plan_example_50_30_20_on_70(self): - amounts = allocate_operator_split_legs(70, [0.5, 0.3, 0.2]) - assert amounts == [35, 21, 14] - assert sum(amounts) == 70 - - def test_realistic_50_30_20_on_5575(self): - amounts = allocate_operator_split_legs(5575, [0.5, 0.3, 0.2]) - # 50%: round(2787.5) = 2788; 30%: round(1672.5) = 1672; last absorbs - # remainder: 5575 - 2788 - 1672 = 1115. - # Note: round() uses banker's rounding so 2787.5 → 2788 actually - # because 2788 is even. Confirm by total invariant. - assert sum(amounts) == 5575 - assert len(amounts) == 3 - - def test_single_leg_full_remainder(self): - amounts = allocate_operator_split_legs(100, [1.0]) - assert amounts == [100] - - def test_zero_operator_fee_zeros_all_legs(self): - amounts = allocate_operator_split_legs(0, [0.5, 0.5]) - assert amounts == [0, 0] - - def test_empty_legs_list_returns_empty(self): - amounts = allocate_operator_split_legs(100, []) - assert amounts == [] - - def test_last_leg_absorbs_rounding_remainder(self): - # 100 / 3 ≈ 33.33 each; rounding makes the first two 33 and last 34. - amounts = allocate_operator_split_legs(100, [1 / 3, 1 / 3, 1 / 3]) - assert sum(amounts) == 100 - assert amounts[0] == round(100 / 3) # 33 - assert amounts[1] == round(100 / 3) # 33 - # Last leg absorbs the rounding (34, not 33) so total == 100. - assert amounts[2] == 100 - amounts[0] - amounts[1] - - @pytest.mark.parametrize( - "operator_fee,fractions", - [ - (1, [0.5, 0.5]), - (7, [0.5, 0.3, 0.2]), - (100, [0.5, 0.5]), - (5575, [0.5, 0.3, 0.2]), - (1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]), - ], - ) - def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions): - amounts = allocate_operator_split_legs(operator_fee, fractions) - assert sum(amounts) == operator_fee - assert all(a >= 0 for a in amounts) - - -class TestEndToEndScenarios: - """The full two-stage split — super then operator legs — composed.""" - - def test_plan_example_full(self): - # 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2]. - platform, operator = split_two_stage_commission(100, 0.30) - legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2]) - assert platform == 30 - assert legs == [35, 21, 14] - assert platform + sum(legs) == 100 - - def test_super_fraction_zero_full_pipeline(self): - platform, operator = split_two_stage_commission(7965, 0.0) - legs = allocate_operator_split_legs(operator, [1.0]) - assert platform == 0 - assert legs == [7965] - assert platform + sum(legs) == 7965 - - def test_super_fraction_one_full_pipeline(self): - platform, operator = split_two_stage_commission(7965, 1.0) - legs = allocate_operator_split_legs(operator, [0.5, 0.5]) - assert platform == 7965 - # Operator has zero to distribute; both legs get zero. - assert legs == [0, 0] - assert platform + sum(legs) == 7965 - - -class TestPartialDispenseSplitRatio: - """The partial-dispense recompute (H6 fix) must preserve the ORIGINAL - platform/operator ratio from the landed settlement — NOT re-derive - from the current super_fee_fraction. - - These tests cover the math; the actual function lives in distribution.py - and is exercised end-to-end via integration testing. Here we verify the - invariant a future maintainer should never break. - """ - - def _recompute(self, original_fee, original_platform_fee, new_fee): - """Mirror of the ratio math in apply_partial_dispense_and_redistribute.""" - if original_fee > 0: - ratio = original_platform_fee / original_fee - else: - ratio = 0.0 - new_platform = round(new_fee * ratio) - new_platform = max(0, min(new_platform, new_fee)) - new_operator = new_fee - new_platform - return new_platform, new_operator - - def test_plan_scenario_30pct_lands_then_partial(self): - # Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70. - # Partial-dispense to 50% gross → new_fee = 50. - # Original ratio (30/100 = 0.30) preserved. - new_platform, new_operator = self._recompute(100, 30, 50) - assert new_platform == 15 - assert new_operator == 35 - assert new_platform + new_operator == 50 - - def test_super_changed_rate_doesnt_affect_existing_settlement(self): - # Landed at super_fee_fraction=0.30 (fee 7965, platform 2390). - # Super then raises rate to 50% globally. Operator partial-dispenses - # to 50% gross → new_fee = 3982 (round(7965 * 0.5)). - # Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%. - new_platform, new_operator = self._recompute(7965, 2390, 3982) - # Expected with original ratio: round(3982 * 0.30006...) = 1195 - # With (broken) current rate of 50%: would be 1991 — much higher. - assert 1190 <= new_platform <= 1200 - assert new_platform + new_operator == 3982 - # Original platform share was ~30%; preserved within rounding. - assert abs(new_platform / 3982 - 2390 / 7965) < 0.001 - - def test_zero_original_fee_yields_zero_platform(self): - new_platform, new_operator = self._recompute(0, 0, 0) - assert new_platform == 0 - assert new_operator == 0 - - def test_invariant_sum_equals_new_fee(self): - # Random-ish parameter sweep over realistic values. - cases = [ - (100, 30, 50), - (100, 0, 50), # original platform_fee was 0 (super_fraction=0) - (100, 100, 50), # original platform_fee was 100 (super_fraction=100) - (7965, 2390, 3982), - (7965, 7965, 3982), - (1_000_000, 333_333, 250_000), - ] - for orig_comm, orig_plat, new_comm in cases: - new_platform, new_operator = self._recompute( - orig_comm, orig_plat, new_comm - ) - assert new_platform + new_operator == new_comm, ( - f"sum invariant violated: {orig_comm=} {orig_plat=} " - f"{new_comm=} → {new_platform=} {new_operator=}" - ) - assert 0 <= new_platform <= new_comm -- 2.53.0 From d9e8a04b8b56d372a5ee721e9c22ece3690fae29 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 14:34:25 +0200 Subject: [PATCH 4/5] feat(v2): record fee_mismatch_sats per settlement, Phase 1 (#38 4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 observability per coord-log §2026-06-01T07:00Z (option A locked: always record, no enforce_fee_match gate): fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats) Positive = bitspire over-reported; negative = under-reported; zero = exact match. Recorded unconditionally on every settlement; WARN- logged via loguru only when |delta| > tolerance, where tolerance = max(1, int(principal_sats * 0.001)) — 1-sat floor with 0.1% relative ceiling. bitspire.py:parse_settlement: - Computes the delta after split_principal_based returns. - WARN log line carries bitspire_fee_sats / expected / delta / tolerance / principal / both fractions / tx_type / machine-npub prefix for triage queries. - Always stamps fee_mismatch_sats onto CreateDcaSettlementData. - Comment explains the pre-Layer-3 expectation: large deltas are expected while the ATM hardcodes 7.77% cash-out (aiolabs/lamassu- next#57); the data here will quiet once Layer 3 ships. crud.py:create_settlement_idempotent: extends the INSERT to persist the new column. Tests: - tests/conftest.py: `loguru_capture` fixture — loguru routes to a pre-bound stderr sink that pytest's caplog (stdlib only) misses and capsys can't see; the fixture adds a list-sink for the test's duration. Reusable for future log-behavior tests. - tests/test_fee_mismatch_recording.py: 8 cases covering exact-match zero delta, bitspire over- and under-reporting, the pre-Layer-3 large-delta scenario, within-tolerance silence, over-tolerance warning, diagnostic-fields presence in the WARN line, and the 1-sat floor on tiny-principal settlements. 164/164 tests green. Phase 2 (reject on out-of-tolerance) lands as a follow-up once observability data justifies the tighter posture. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:00Z (lnbits advisory + option A lock). Co-Authored-By: Claude Opus 4.7 --- bitspire.py | 22 ++++ crud.py | 5 +- tests/conftest.py | 32 +++++ tests/test_fee_mismatch_recording.py | 179 +++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_fee_mismatch_recording.py diff --git a/bitspire.py b/bitspire.py index cbf6c67..59192b2 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,6 +17,8 @@ from __future__ import annotations import json from typing import Any, Optional +from loguru import logger + from .calculations import split_principal_based from .models import CreateDcaSettlementData, Machine, SuperConfig @@ -275,6 +277,25 @@ def parse_settlement( platform_fee_sats, operator_fee_sats = split_principal_based( principal_sats, super_frac, operator_frac ) + # Phase-1 observability per aiolabs/satmachineadmin#38 + coord-log + # §2026-06-01T07:00Z (option A locked): compare bitspire's reported + # fee_sats against satmachineadmin's recompute, log on out-of- + # tolerance drift, record the delta unconditionally for triage. + # Phase 2 (settlement-reject) lands after observability data. + fee_mismatch_sats = fee_sats - (platform_fee_sats + operator_fee_sats) + tolerance = max(1, int(principal_sats * 0.001)) + if abs(fee_mismatch_sats) > tolerance: + logger.warning( + f"bitspire fee mismatch on payment {payment_hash[:12]}...: " + f"bitspire_fee_sats={fee_sats} expected={platform_fee_sats + operator_fee_sats} " + f"delta={fee_mismatch_sats} tolerance={tolerance} " + f"principal={principal_sats} super_frac={super_frac:.4f} " + f"operator_frac={operator_frac:.4f} tx_type={tx_type} " + f"machine={machine.machine_npub[:12]}... — " + "Phase 1 observability only, no behavior change. Pre-Layer-3 " + "(lamassu-next#57) the ATM still hardcodes fee fractions, so " + "large deltas here are expected until that ships." + ) exchange_rate = _coerce_float(extra.get("exchange_rate")) if exchange_rate is None or exchange_rate <= 0: # Without exchange rate we can't compute fiat. Use 1.0 as a stand-in @@ -301,6 +322,7 @@ def parse_settlement( fee_sats=fee_sats, platform_fee_sats=platform_fee_sats, operator_fee_sats=operator_fee_sats, + fee_mismatch_sats=fee_mismatch_sats, tx_type=tx_type, bills_json=_json_dumps(extra.get("bills")), cassettes_json=_json_dumps(extra.get("cassettes")), diff --git a/crud.py b/crud.py index e7ca6ea..444a984 100644 --- a/crud.py +++ b/crud.py @@ -601,13 +601,13 @@ async def create_settlement_idempotent( INSERT INTO satoshimachine.dca_settlements (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, - fee_sats, platform_fee_sats, operator_fee_sats, + fee_sats, platform_fee_sats, operator_fee_sats, fee_mismatch_sats, tx_type, bills_json, cassettes_json, status, error_message, created_at) VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, :bitspire_txid, :wire_sats, :fiat_amount, :fiat_code, :exchange_rate, :principal_sats, :fee_sats, - :platform_fee_sats, :operator_fee_sats, + :platform_fee_sats, :operator_fee_sats, :fee_mismatch_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -625,6 +625,7 @@ async def create_settlement_idempotent( "fee_sats": data.fee_sats, "platform_fee_sats": data.platform_fee_sats, "operator_fee_sats": data.operator_fee_sats, + "fee_mismatch_sats": data.fee_mismatch_sats, "tx_type": data.tx_type, "bills_json": data.bills_json, "cassettes_json": data.cassettes_json, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..39fe975 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +""" +Pytest configuration for the satmachineadmin extension test suite. + +Provides a `loguru_capture` fixture for tests that need to verify +loguru WARN/ERROR side-effects. Loguru attaches its default sink to +sys.stderr at import time, before pytest's `capsys` wraps stderr, so +neither `caplog` (stdlib logging only) nor `capsys` reliably sees +loguru output. The fixture adds a list-sink for the test's duration +and removes it on teardown. +""" + +from typing import Generator, List + +import pytest +from loguru import logger + + +@pytest.fixture +def loguru_capture() -> Generator[List[str], None, None]: + """Capture loguru log records into a list for the test's duration. + + Usage: + def test_warns_on_X(loguru_capture): + do_thing_that_warns() + assert any("expected message" in msg for msg in loguru_capture) + """ + captured: List[str] = [] + handler_id = logger.add( + captured.append, level="WARNING", format="{level} {message}" + ) + yield captured + logger.remove(handler_id) diff --git a/tests/test_fee_mismatch_recording.py b/tests/test_fee_mismatch_recording.py new file mode 100644 index 0000000..7e5bb80 --- /dev/null +++ b/tests/test_fee_mismatch_recording.py @@ -0,0 +1,179 @@ +""" +Tests for `dca_settlements.fee_mismatch_sats` Phase-1 observability +(aiolabs/satmachineadmin#38, coord-log §2026-06-01T07:00Z — option A +locked: always record, no enforce_fee_match gate). + +Each settlement records: + + fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats) + +Positive = bitspire over-reported (claimed more fee than satmachineadmin +recomputed against principal). Negative = bitspire under-reported. +Zero = exact match. + +Tolerance for the WARN log is `max(1, int(principal_sats * 0.001))` — +1-sat floor, 0.1% relative ceiling. Sub-tolerance drift records the +delta silently; over-tolerance drift logs a WARNING. The delta is +recorded unconditionally regardless of tolerance — sub-tolerance data +is still useful triage data once aggregated. + +Phase 2 (settlement-reject on out-of-tolerance) is a follow-up; this +layer is observability-only. +""" + +from datetime import datetime + +from ..bitspire import parse_settlement +from ..models import Machine, SuperConfig + + +_NOW = datetime(2026, 6, 1, 12, 0, 0) + + +def _machine(op_out: float = 0.0) -> Machine: + return Machine( + id="m1", + operator_user_id="op1", + machine_npub="a" * 64, + wallet_id="w1", + name="Test", + location=None, + fiat_code="EUR", + is_active=True, + operator_cash_in_fee_fraction=0.0, + operator_cash_out_fee_fraction=op_out, + created_at=_NOW, + updated_at=_NOW, + ) + + +def _super_config(out_frac: float = 0.0) -> SuperConfig: + return SuperConfig( + id="default", + super_cash_in_fee_fraction=0.0, + super_cash_out_fee_fraction=out_frac, + super_fee_wallet_id="super-wallet", + updated_at=_NOW, + ) + + +def _bitspire_extra(principal_sats: int, fee_sats: int) -> dict: + return { + "source": "bitspire", + "type": "cash_out", + "principal_sats": principal_sats, + "fee_sats": fee_sats, + "fee_fraction": fee_sats / principal_sats if principal_sats else 0.0, + "exchange_rate": 0.00001, + "fiat_amount": 100.0, + "currency": "EUR", + "txid": "fake-txid", + "nostr_sender_pubkey": "a" * 64, + } + + +def _parse(machine, super_cfg, principal_sats, fee_sats): + """Helper: build extra + invoke parse_settlement with cash-out wire + invariant (wire = principal + fee).""" + extra = _bitspire_extra(principal_sats, fee_sats) + return parse_settlement( + machine=machine, + payment_hash="ph_test", + wire_sats=principal_sats + fee_sats, + extra=extra, + super_config=super_cfg, + ) + + +class TestFeeMismatchSatsRecording: + def test_zero_mismatch_when_bitspire_matches_recompute(self): + """super=3%, operator=5%, total=8%. Bitspire reports + principal=100_000 fee=8_000 → 100_000 * 0.08 = 8_000 → mismatch=0.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_000) + assert data.platform_fee_sats == 3_000 + assert data.operator_fee_sats == 5_000 + assert data.fee_mismatch_sats == 0 + + def test_positive_mismatch_when_bitspire_over_reports(self): + """super=3%, operator=5% → expected=8_000. Bitspire claims 9_000. + Delta = +1_000 (over-reported).""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000) + assert data.fee_mismatch_sats == 1_000 + + def test_negative_mismatch_when_bitspire_under_reports(self): + """super=3%, operator=5% → expected=8_000. Bitspire claims 7_000. + Delta = -1_000 (under-reported).""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_000) + assert data.fee_mismatch_sats == -1_000 + + def test_pre_layer3_records_large_delta(self): + """Real-world Phase-1 scenario before Layer 3 (lamassu-next#57) + ships: ATM hardcodes 7.77% cash-out; operator configures 5% + operator + 3% super = 8% total. Bitspire reports + 100_000 * 0.0777 = 7_770 sats; satmachineadmin recomputes 8_000. + Delta is large and visible for triage; behavior unchanged.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_770) + # Expected = 3_000 + 5_000 = 8_000; bitspire claims 7_770. + assert data.fee_mismatch_sats == -230 + + +class TestFeeMismatchWarningLogging: + """Tolerance = max(1, int(principal_sats * 0.001)). + For principal=100_000 → tolerance=100. For principal=500 → tolerance=1. + + Uses the `loguru_capture` fixture (defined in conftest.py) to read + the WARN log line — pytest's `caplog` only sees stdlib logging, + and `capsys` misses loguru's pre-bound stderr sink. + """ + + def test_within_tolerance_does_not_warn(self, loguru_capture): + """1-sat delta at principal=100_000 → tolerance=100 → no warn.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_001) + assert data.fee_mismatch_sats == 1 + # Still recorded — the delta is small, the WARN is suppressed. + assert not any("fee mismatch" in m.lower() for m in loguru_capture) + + def test_outside_tolerance_logs_warning(self, loguru_capture): + """101-sat delta at principal=100_000 → tolerance=100 → warns.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + # bitspire claims 8_101 (= expected 8_000 + 101 over) + data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_101) + assert data.fee_mismatch_sats == 101 + assert any("fee mismatch" in m.lower() for m in loguru_capture) + + def test_warning_includes_diagnostic_fields(self, loguru_capture): + """WARN log line must carry the fields a triage-time operator + needs: bitspire's claim, the expected total, the delta, the + principal, both fractions, and tx_type.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + _parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000) + log_text = "".join(loguru_capture) + assert "bitspire_fee_sats=9000" in log_text + assert "expected=8000" in log_text + assert "delta=1000" in log_text + assert "principal=100000" in log_text + assert "tx_type=cash_out" in log_text + + def test_one_sat_floor_warns_on_tiny_principal(self, loguru_capture): + """At principal=500, tolerance=max(1, 0.5)=1. A 2-sat delta + triggers the warning — the floor exists so tiny-principal + settlements don't go un-policed.""" + machine = _machine(op_out=0.05) + super_cfg = _super_config(out_frac=0.03) + # principal=500 → expected fee = 500 * 0.08 = 40 sats. + # Bitspire claims 42 → delta=2. Tolerance=max(1, 0)=1. Warns. + data = _parse(machine, super_cfg, principal_sats=500, fee_sats=42) + assert data.fee_mismatch_sats == 2 + assert any("fee mismatch" in m.lower() for m in loguru_capture) -- 2.53.0 From 10f4b50ca51cd169c3ac4151d510aa4b57df09a7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 14:46:27 +0200 Subject: [PATCH 5/5] feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the new directional fee fields in the admin dashboard so operators + the LNbits super can configure cash-in and cash-out fees independently: Templates (`templates/satmachineadmin/index.html`): - Platform fee banner now shows both directional super fractions side-by-side ("cash-in X% · cash-out Y% of each transaction's principal"). Wording updated to "principal" not "commission" since the math is now principal-based. - Super-fee edit dialog: replaces the single q-input with two (super_cash_in_fee_fraction + super_cash_out_fee_fraction); each capped at 0.15 via max attr (visual hint; server enforces). - Add-machine + edit-machine dialogs both gain operator_cash_in_fee_ fraction + operator_cash_out_fee_fraction inputs with the same 0.15 cap hint. Hint text mentions the "sits on top of platform fee, total capped at 15% per direction" semantics so operators understand the layering. JS (`static/js/index.js`): - superFeeDialog.data shape switches to the new directional fields. - openSuperFeeDialog / submitSuperFee load + POST the new shape. - _emptyMachineForm / _cleanMachineForm pass through operator directional fields (Number-coerced, default 0). - openEditMachineDialog / submitEditMachine include the operator fee fields in the form data + PUT body. - New computed `superAnyFee` drives the banner styling (sum of both directional fractions — non-zero → blue active banner; zero → muted grey "free instance" banner). All Quasar UMD components use explicit close tags per the UMD-mode parsing rule. Migration carry-over verified in dev container: pre-m009 super_fee_fraction=0.33 backfilled to super_cash_in=0.33 + super_cash_out=0.33 on migrate-up. Note this puts existing dev instances above the new 15% cap; operators will see the cap validation error on their next super-config save and must adjust to ≤0.15 per direction. Production aiolabs/server-deploy will land at 0.03 on both directions (well under cap). 164/164 tests green. Layer 1 (#38) complete; Layer 2 (#39) wire- format publisher is the next milestone. Refs: aiolabs/satmachineadmin#37 (parent), #38 (closes Layer 1). Co-Authored-By: Claude Opus 4.7 --- static/js/index.js | 41 +++++++++++++++---- templates/satmachineadmin/index.html | 59 ++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 19 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 96b98ef..4da7ee6 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,7 +100,11 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: {super_fee_fraction: 0, super_fee_wallet_id: ''} + data: { + super_cash_in_fee_fraction: 0, + super_cash_out_fee_fraction: 0, + super_fee_wallet_id: '' + } }, // UI configuration ----------------------------------------------- @@ -266,6 +270,17 @@ window.app = Vue.createApp({ }, computed: { + superAnyFee() { + // Banner styling key — true when either directional super fee is + // non-zero, so the banner reads as "active platform fee" instead + // of the muted grey "free instance" state. + const c = this.superConfig + if (!c) return 0 + return ( + Number(c.super_cash_in_fee_fraction || 0) + + Number(c.super_cash_out_fee_fraction || 0) + ) + }, walletOptions() { // g.user is sometimes null on initial mount in LNbits 1.4 — guard it. const wallets = this.g?.user?.wallets || [] @@ -549,7 +564,10 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- openSuperFeeDialog() { this.superFeeDialog.data = { - super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0, + super_cash_in_fee_fraction: + this.superConfig?.super_cash_in_fee_fraction ?? 0, + super_cash_out_fee_fraction: + this.superConfig?.super_cash_out_fee_fraction ?? 0, super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true @@ -562,7 +580,8 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'PUT', SUPER_FEE_PATH, null, { - super_fee_fraction: Number(d.super_fee_fraction), + super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), + super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) @@ -705,7 +724,9 @@ window.app = Vue.createApp({ location: machine.location || '', wallet_id: machine.wallet_id, fiat_code: machine.fiat_code, - is_active: machine.is_active + is_active: machine.is_active, + operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0, + operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0 } this.editMachineDialog.show = true }, @@ -723,7 +744,9 @@ window.app = Vue.createApp({ location: d.location, wallet_id: d.wallet_id, fiat_code: d.fiat_code, - is_active: d.is_active + is_active: d.is_active, + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 } ) const idx = this.machines.findIndex(m => m.id === data.id) @@ -1475,7 +1498,9 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ' + fiat_code: 'GTQ', + operator_cash_in_fee_fraction: 0, + operator_cash_out_fee_fraction: 0 } }, @@ -1485,7 +1510,9 @@ window.app = Vue.createApp({ wallet_id: d.wallet_id, name: (d.name || '').trim() || null, location: (d.location || '').trim() || null, - fiat_code: (d.fiat_code || 'GTQ').trim() + fiat_code: (d.fiat_code || 'GTQ').trim(), + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 } }, diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 8b3ddf3..ffdb730 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,17 +31,19 @@ + :class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> LNbits platform fee: - ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% - of each transaction's commission. + cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }% + · + cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }% + of each transaction's principal. - Your remainder splits per the rules below. + Operator's per-machine fee rides on top of these.