From d9e8a04b8b56d372a5ee721e9c22ece3690fae29 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 14:34:25 +0200 Subject: [PATCH] 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)