spirekeeper/tests/test_calculations.py
Padreug d717a6e214 refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation
Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:

  - `wire_sats` — actual Lightning payment amount (direction-agnostic;
                  was `gross_sats`, only "gross" for cash-out)
  - `principal_sats` — market-rate sats before commission (unchanged)
  - `fee_sats` — commission (was `commission_sats` internally;
                 already the wire format)
  - `fee_fraction` — commission rate as unit fraction in [0, 1]
                     (was `*_pct` / `fee_percent`; eliminates the
                     latent 100x bug from `feePercent * 100` on the
                     lamassu-next side)

Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:

  - cash-out: wire_sats == principal_sats + fee_sats
  - cash-in:  wire_sats == principal_sats - fee_sats
              AND fee_sats <= principal_sats

Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.

Schema changes (m001 + m006):

  - dca_settlements.gross_sats              -> wire_sats
  - dca_settlements.commission_sats         -> fee_sats
  - super_config.super_fee_pct              -> super_fee_fraction
  - dca_commission_splits.pct               -> fraction
  - dca_machines.fallback_commission_pct    DROPPED (obsolete)
  - dca_settlements.used_fallback_split     DROPPED (obsolete)

m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.

Obsolete code removed (Lamassu-era reverse-derivation):

  - calculations.calculate_commission — back-derived principal+fee
    from gross-with-commission-baked-in. v2 stamps both directly.
  - calculations.calculate_exchange_rate — bitSpire stamps directly.
  - bitspire._parse_fallback — sole caller of calculate_commission.
  - Machine.fallback_commission_fraction — only read by _parse_fallback.
  - DcaSettlement.used_fallback_split — only written by _parse_fallback.

parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.

Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.

Tests:
  - test_calculations.py: kept distribution tests; deleted
    calculate_commission + calculate_exchange_rate tests.
  - test_two_stage_split.py: renamed variables; rewrote docstring
    value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
  - test_nostr_attribution.py: dropped fallback_commission_fraction
    from machine fixture.
  - 72/72 pass on regtest container.

Cross-codebase follow-ups tracked in coordination log:
  - lamassu-next: rename `fee_percent` -> `fee_fraction` on
    Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
  - atm-tui: read `fee_fraction` column in db.zig.

Memory artefacts:
  - reference_sat_amount_vocabulary.md (canonical + invariants)
  - feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:08:30 +02:00

135 lines
4.9 KiB
Python

"""
Tests for DCA transaction calculations.
Covers the pure-function helpers that survive the 2026-05-26 cleanup:
- calculate_distribution (proportional split across LPs by balance)
The previous test surface for `calculate_commission` and
`calculate_exchange_rate` was deleted alongside those functions — the
Lamassu-era reverse-derivation is obsolete now that bitSpire stamps
`principal_sats` and `fee_sats` directly on Payment.extra.
Two-stage commission split tests live in `test_two_stage_split.py`.
"""
# Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_distribution
# =============================================================================
# DISTRIBUTION CALCULATION TESTS
# =============================================================================
class TestDistributionCalculation:
"""Tests for proportional distribution logic."""
def test_single_client_gets_all(self):
"""Single client should receive entire distribution."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={"client_a": 500.00}
)
assert distributions == {"client_a": 100000}
def test_two_clients_equal_balance(self):
"""Two clients with equal balance should split evenly."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 500.00
}
)
assert distributions["client_a"] == 50000
assert distributions["client_b"] == 50000
assert sum(distributions.values()) == 100000
def test_two_clients_unequal_balance(self):
"""Two clients with 75/25 balance split."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 750.00,
"client_b": 250.00
}
)
assert distributions["client_a"] == 75000
assert distributions["client_b"] == 25000
assert sum(distributions.values()) == 100000
def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount."""
test_cases = [
{"a": 100.0},
{"a": 100.0, "b": 100.0},
{"a": 100.0, "b": 200.0, "c": 300.0},
{"a": 33.33, "b": 33.33, "c": 33.34}, # Tricky rounding case
{"a": 1000.0, "b": 1.0}, # Large imbalance
]
for client_balances in test_cases:
for base_amount in [100, 1000, 10000, 100000, 258835]:
distributions = calculate_distribution(base_amount, client_balances)
total_distributed = sum(distributions.values())
assert total_distributed == base_amount, \
f"Distribution sum {total_distributed} != base {base_amount} " \
f"for balances {client_balances}"
def test_zero_balance_client_excluded(self):
"""Clients with zero balance should be excluded."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 0.0,
"client_c": 500.00
}
)
assert "client_b" not in distributions
assert distributions["client_a"] == 50000
assert distributions["client_c"] == 50000
def test_tiny_balance_excluded(self):
"""Clients with balance < 0.01 should be excluded."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 500.00,
"client_b": 0.005, # Less than threshold
}
)
assert "client_b" not in distributions
assert distributions["client_a"] == 100000
def test_no_eligible_clients_returns_empty(self):
"""If no clients have balance, return empty distribution."""
distributions = calculate_distribution(
base_amount_sats=100000,
client_balances={
"client_a": 0.0,
"client_b": 0.0,
}
)
assert distributions == {}
def test_many_clients_distribution(self):
"""Test distribution with many clients."""
# 10 clients with varying balances
client_balances = {f"client_{i}": float(i * 100) for i in range(1, 11)}
distributions = calculate_distribution(1000000, client_balances)
assert len(distributions) == 10
assert sum(distributions.values()) == 1000000
# Verify proportionality (client_10 should get ~18% with balance 1000)
# Total balance = 100+200+...+1000 = 5500
# client_10 proportion = 1000/5500 ≈ 18.18%
assert distributions["client_10"] > distributions["client_1"]