satmachineadmin/tests/test_fee_mismatch_recording.py
Padreug d9e8a04b8b 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>
2026-06-01 14:34:25 +02:00

179 lines
7.2 KiB
Python

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