feat(v2): record fee_mismatch_sats per settlement, Phase 1 (#38 4/5)

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 14:34:25 +02:00
commit d9e8a04b8b
4 changed files with 236 additions and 2 deletions

View file

@ -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")),

View file

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

32
tests/conftest.py Normal file
View file

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

View file

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