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:
parent
1babdfbf06
commit
d9e8a04b8b
4 changed files with 236 additions and 2 deletions
22
bitspire.py
22
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")),
|
||||
|
|
|
|||
5
crud.py
5
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,
|
||||
|
|
|
|||
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal 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)
|
||||
179
tests/test_fee_mismatch_recording.py
Normal file
179
tests/test_fee_mismatch_recording.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue