Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
7.2 KiB
Python
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 spirekeeper
|
|
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; spirekeeper 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)
|