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>
This commit is contained in:
Padreug 2026-05-26 20:08:30 +02:00
commit d717a6e214
12 changed files with 530 additions and 681 deletions

View file

@ -1,114 +1,19 @@
"""
Tests for DCA transaction calculations using empirical data.
Tests for DCA transaction calculations.
These tests verify commission and distribution calculations against
real Lamassu transaction data to ensure the math is correct.
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 pytest
from decimal import Decimal
from typing import Dict, List, Tuple
# Import from the parent package (following lnurlp pattern)
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
# =============================================================================
# COMMISSION CALCULATION TESTS
# =============================================================================
class TestCommissionCalculation:
"""Tests for commission calculation logic."""
# Empirical test cases: (crypto_atoms, commission%, discount%, expected_base, expected_commission)
# Formula: base = round(crypto_atoms / (1 + effective_commission))
# Where: effective_commission = commission_percentage * (100 - discount) / 100
EMPIRICAL_COMMISSION_CASES = [
# =============================================================
# REAL LAMASSU TRANSACTIONS (extracted from production database)
# =============================================================
# 8.75% commission, no discount - small transaction
# 15600 / 1.0875 = 14344.827... → 14345
(15600, 0.0875, 0.0, 14345, 1255),
# 8.75% commission, no discount - large transaction
# 309200 / 1.0875 = 284322.298... → 284322
(309200, 0.0875, 0.0, 284322, 24878),
# 5.5% commission, no discount
# 309500 / 1.055 = 293364.928... → 293365
(309500, 0.055, 0.0, 293365, 16135),
# 5.5% commission with 100% discount (no commission charged)
# effective = 0.055 * (100-100)/100 = 0
(292400, 0.055, 100.0, 292400, 0),
# 5.5% commission with 90% discount
# effective = 0.055 * (100-90)/100 = 0.0055
# 115000 / 1.0055 = 114370.96... → 114371
(115000, 0.055, 90.0, 114371, 629),
# 5.5% commission, no discount - 1300 GTQ transaction
# 205600 / 1.055 = 194881.516... → 194882
# Note: This tx showed 0.01 GTQ rounding discrepancy in per-client fiat
(205600, 0.055, 0.0, 194882, 10718),
# =============================================================
# SYNTHETIC TEST CASES (edge cases)
# =============================================================
# Zero commission - all goes to base
(100000, 0.0, 0.0, 100000, 0),
# Small amount edge case (1 sat minimum)
(100, 0.03, 0.0, 97, 3),
]
@pytest.mark.parametrize(
"crypto_atoms,commission_pct,discount,expected_base,expected_commission",
EMPIRICAL_COMMISSION_CASES,
ids=[
"lamassu_8.75pct_small",
"lamassu_8.75pct_large",
"lamassu_5.5pct_no_discount",
"lamassu_5.5pct_100pct_discount",
"lamassu_5.5pct_90pct_discount",
"lamassu_5.5pct_1300gtq",
"zero_commission",
"small_amount_100sats",
]
)
def test_commission_calculation(
self,
crypto_atoms: int,
commission_pct: float,
discount: float,
expected_base: int,
expected_commission: int
):
"""Test commission calculation against empirical data."""
base, commission, _ = calculate_commission(crypto_atoms, commission_pct, discount)
assert base == expected_base, f"Base amount mismatch: got {base}, expected {expected_base}"
assert commission == expected_commission, f"Commission mismatch: got {commission}, expected {expected_commission}"
# Invariant: base + commission must equal total
assert base + commission == crypto_atoms, "Base + commission must equal total crypto_atoms"
def test_commission_invariant_always_sums_to_total(self):
"""Commission + base must always equal the original amount."""
test_values = [1, 100, 1000, 10000, 100000, 266800, 1000000]
commission_rates = [0.0, 0.01, 0.03, 0.05, 0.10]
discounts = [0.0, 10.0, 25.0, 50.0]
for crypto_atoms in test_values:
for comm_rate in commission_rates:
for discount in discounts:
base, commission, _ = calculate_commission(crypto_atoms, comm_rate, discount)
assert base + commission == crypto_atoms, \
f"Invariant failed: {base} + {commission} != {crypto_atoms} " \
f"(rate={comm_rate}, discount={discount})"
from ..calculations import calculate_distribution
# =============================================================================
@ -157,7 +62,6 @@ class TestDistributionCalculation:
def test_distribution_invariant_sums_to_total(self):
"""Total distributed sats must always equal base amount."""
# Test with various client configurations
test_cases = [
{"a": 100.0},
{"a": 100.0, "b": 100.0},
@ -215,156 +119,6 @@ class TestDistributionCalculation:
assert distributions == {}
def test_fiat_round_trip_invariant(self):
"""
Verify that distributed sats convert back to original fiat amount.
The sum of each client's fiat equivalent should equal the original
fiat amount (within rounding tolerance).
"""
# Use real Lamassu transaction data
test_cases = [
# (crypto_atoms, fiat_amount, commission_pct, discount, client_balances)
(309200, 2000.0, 0.0875, 0.0, {"a": 1000.0, "b": 1000.0}),
(309500, 2000.0, 0.055, 0.0, {"a": 500.0, "b": 1000.0, "c": 500.0}),
(292400, 2000.0, 0.055, 100.0, {"a": 800.0, "b": 1200.0}),
(115000, 800.0, 0.055, 90.0, {"a": 400.0, "b": 400.0}),
# Transaction that showed 0.01 GTQ rounding discrepancy with 4 clients
(205600, 1300.0, 0.055, 0.0, {"a": 1.0, "b": 986.0, "c": 14.0, "d": 4.0}),
]
for crypto_atoms, fiat_amount, comm_pct, discount, client_balances in test_cases:
# Calculate commission and base amount
base_sats, _, _ = calculate_commission(crypto_atoms, comm_pct, discount)
# Calculate exchange rate
exchange_rate = calculate_exchange_rate(base_sats, fiat_amount)
# Distribute sats to clients
distributions = calculate_distribution(base_sats, client_balances)
# Convert each client's sats back to fiat
total_fiat_distributed = sum(
sats / exchange_rate for sats in distributions.values()
)
# Should equal original fiat amount (within small rounding tolerance)
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
# =============================================================================
# EMPIRICAL END-TO-END TESTS
# =============================================================================
class TestEmpiricalTransactions:
"""
End-to-end tests using real Lamassu transaction data.
Add your empirical test cases here! Each case should include:
- Transaction details (crypto_atoms, fiat, commission, discount)
- Client balances at time of transaction
- Expected distribution outcome
"""
# TODO: Add your empirical data here
# Example structure:
EMPIRICAL_SCENARIOS = [
{
"name": "real_tx_266800sats_two_equal_clients",
"transaction": {
"crypto_atoms": 266800,
"fiat_amount": 2000,
"commission_percentage": 0.03,
"discount": 0.0,
},
"client_balances": {
"client_a": 1000.00, # 50% of total
"client_b": 1000.00, # 50% of total
},
# 266800 / 1.03 = 259029
"expected_base_sats": 259029,
"expected_commission_sats": 7771,
"expected_distributions": {
# 259029 / 2 = 129514.5 → both get 129514 or 129515
# With banker's rounding: 129514.5 → 129514 (even)
# Remainder of 1 sat goes to first client by fractional sort
"client_a": 129515,
"client_b": 129514,
},
},
# Add more scenarios from your real data!
]
@pytest.mark.parametrize(
"scenario",
EMPIRICAL_SCENARIOS,
ids=[s["name"] for s in EMPIRICAL_SCENARIOS]
)
def test_empirical_scenario(self, scenario):
"""Test full transaction flow against empirical data."""
tx = scenario["transaction"]
# Calculate commission
base, commission, _ = calculate_commission(
tx["crypto_atoms"],
tx["commission_percentage"],
tx["discount"]
)
assert base == scenario["expected_base_sats"], \
f"Base amount mismatch in {scenario['name']}"
assert commission == scenario["expected_commission_sats"], \
f"Commission mismatch in {scenario['name']}"
# Calculate distribution
distributions = calculate_distribution(
base,
scenario["client_balances"]
)
# Verify each client's allocation
for client_id, expected_sats in scenario["expected_distributions"].items():
actual_sats = distributions.get(client_id, 0)
assert actual_sats == expected_sats, \
f"Distribution mismatch for {client_id} in {scenario['name']}: " \
f"got {actual_sats}, expected {expected_sats}"
# Verify total distribution equals base
assert sum(distributions.values()) == base, \
f"Total distribution doesn't match base in {scenario['name']}"
# =============================================================================
# EDGE CASE TESTS
# =============================================================================
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_minimum_amount_1_sat(self):
"""Test with minimum possible amount (1 sat)."""
base, commission, _ = calculate_commission(1, 0.03, 0.0)
# With 3% commission on 1 sat, base rounds to 1, commission to 0
assert base + commission == 1
def test_large_transaction(self):
"""Test with large transaction (100 BTC worth of sats)."""
crypto_atoms = 10_000_000_000 # 100 BTC in sats
base, commission, _ = calculate_commission(crypto_atoms, 0.03, 0.0)
assert base + commission == crypto_atoms
assert commission > 0
def test_100_percent_discount(self):
"""100% discount should result in zero commission."""
base, commission, effective = calculate_commission(100000, 0.03, 100.0)
assert effective == 0.0
assert commission == 0
assert base == 100000
def test_many_clients_distribution(self):
"""Test distribution with many clients."""
# 10 clients with varying balances

View file

@ -37,7 +37,6 @@ def _machine(npub: str) -> Machine:
location=None,
fiat_code="EUR",
is_active=True,
fallback_commission_pct=0.05,
created_at=now,
updated_at=now,
)

View file

@ -2,11 +2,12 @@
Tests for the v2 two-stage commission split (super first, operator remainder).
The plan calls out a verification scenario explicitly:
super_fee_pct=30%, operator split 50/30/20 on a 100-sat commission
super_wallet gets 30, operator_self gets 35, employee 21, maint 14.
super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a
100-sat fee super_wallet gets 30, operator legs get 35 / 21 / 14.
Also covers the edge cases: super_fee_pct=0 (no super), super_fee_pct=1.0
(everything to super), single-leg operator ruleset, zero operator fee.
Also covers the edge cases: super_fee_fraction=0.0 (no super takes the
whole fee), super_fee_fraction=1.0 (super takes everything), single-leg
operator ruleset, zero operator fee.
"""
import pytest
@ -18,7 +19,7 @@ from ..calculations import (
class TestSplitTwoStageCommission:
"""Stage-1: super takes super_fee_pct of commission; operator gets rest."""
"""Stage-1: super takes super_fee_fraction of the fee; operator gets rest."""
def test_plan_example_100sats_30pct(self):
platform, operator = split_two_stage_commission(100, 0.30)
@ -33,12 +34,12 @@ class TestSplitTwoStageCommission:
assert operator == 5575 # 7965 - 2390
assert platform + operator == 7965
def test_super_pct_zero_leaves_all_to_operator(self):
def test_super_fraction_zero_leaves_all_to_operator(self):
platform, operator = split_two_stage_commission(7965, 0.0)
assert platform == 0
assert operator == 7965
def test_super_pct_one_takes_everything(self):
def test_super_fraction_one_takes_everything(self):
platform, operator = split_two_stage_commission(7965, 1.0)
assert platform == 7965
assert operator == 0
@ -54,13 +55,13 @@ class TestSplitTwoStageCommission:
assert platform == 0
assert operator == 0
@pytest.mark.parametrize("commission_sats", [1, 7, 100, 7965, 1_000_000])
@pytest.mark.parametrize("super_pct", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
def test_invariant_sum_equals_commission(self, commission_sats, super_pct):
platform, operator = split_two_stage_commission(commission_sats, super_pct)
assert platform + operator == commission_sats
assert 0 <= platform <= commission_sats
assert 0 <= operator <= commission_sats
@pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000])
@pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0])
def test_invariant_sum_equals_commission(self, fee_sats, super_fraction):
platform, operator = split_two_stage_commission(fee_sats, super_fraction)
assert platform + operator == fee_sats
assert 0 <= platform <= fee_sats
assert 0 <= operator <= fee_sats
class TestAllocateOperatorSplitLegs:
@ -102,7 +103,7 @@ class TestAllocateOperatorSplitLegs:
assert amounts[2] == 100 - amounts[0] - amounts[1]
@pytest.mark.parametrize(
"operator_fee,pcts",
"operator_fee,fractions",
[
(1, [0.5, 0.5]),
(7, [0.5, 0.3, 0.2]),
@ -111,8 +112,8 @@ class TestAllocateOperatorSplitLegs:
(1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
],
)
def test_invariant_sum_equals_operator_fee(self, operator_fee, pcts):
amounts = allocate_operator_split_legs(operator_fee, pcts)
def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions):
amounts = allocate_operator_split_legs(operator_fee, fractions)
assert sum(amounts) == operator_fee
assert all(a >= 0 for a in amounts)
@ -121,21 +122,21 @@ class TestEndToEndScenarios:
"""The full two-stage split — super then operator legs — composed."""
def test_plan_example_full(self):
# 100 sats commission, super=30%, operator splits 50/30/20.
# 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2].
platform, operator = split_two_stage_commission(100, 0.30)
legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2])
assert platform == 30
assert legs == [35, 21, 14]
assert platform + sum(legs) == 100
def test_super_pct_zero_full_pipeline(self):
def test_super_fraction_zero_full_pipeline(self):
platform, operator = split_two_stage_commission(7965, 0.0)
legs = allocate_operator_split_legs(operator, [1.0])
assert platform == 0
assert legs == [7965]
assert platform + sum(legs) == 7965
def test_super_pct_one_full_pipeline(self):
def test_super_fraction_one_full_pipeline(self):
platform, operator = split_two_stage_commission(7965, 1.0)
legs = allocate_operator_split_legs(operator, [0.5, 0.5])
assert platform == 7965
@ -147,27 +148,27 @@ class TestEndToEndScenarios:
class TestPartialDispenseSplitRatio:
"""The partial-dispense recompute (H6 fix) must preserve the ORIGINAL
platform/operator ratio from the landed settlement NOT re-derive
from the current super_fee_pct.
from the current super_fee_fraction.
These tests cover the math; the actual function lives in distribution.py
and is exercised end-to-end via integration testing. Here we verify the
invariant a future maintainer should never break.
"""
def _recompute(self, original_commission, original_platform_fee, new_commission):
def _recompute(self, original_fee, original_platform_fee, new_fee):
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
if original_commission > 0:
ratio = original_platform_fee / original_commission
if original_fee > 0:
ratio = original_platform_fee / original_fee
else:
ratio = 0.0
new_platform = round(new_commission * ratio)
new_platform = max(0, min(new_platform, new_commission))
new_operator = new_commission - new_platform
new_platform = round(new_fee * ratio)
new_platform = max(0, min(new_platform, new_fee))
new_operator = new_fee - new_platform
return new_platform, new_operator
def test_plan_scenario_30pct_lands_then_partial(self):
# Landed at super_fee_pct=30%: 100-sat commission → 30 / 70.
# Partial-dispense to 50% gross → new_commission = 50.
# Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70.
# Partial-dispense to 50% gross → new_fee = 50.
# Original ratio (30/100 = 0.30) preserved.
new_platform, new_operator = self._recompute(100, 30, 50)
assert new_platform == 15
@ -175,9 +176,9 @@ class TestPartialDispenseSplitRatio:
assert new_platform + new_operator == 50
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
# Landed at super_fee_pct=30% (commission 7965, platform 2390).
# Landed at super_fee_fraction=0.30 (fee 7965, platform 2390).
# Super then raises rate to 50% globally. Operator partial-dispenses
# to 50% gross → new_commission = 3982 (round(7965 * 0.5)).
# to 50% gross → new_fee = 3982 (round(7965 * 0.5)).
# Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%.
new_platform, new_operator = self._recompute(7965, 2390, 3982)
# Expected with original ratio: round(3982 * 0.30006...) = 1195
@ -187,17 +188,17 @@ class TestPartialDispenseSplitRatio:
# Original platform share was ~30%; preserved within rounding.
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
def test_zero_original_commission_yields_zero_platform(self):
def test_zero_original_fee_yields_zero_platform(self):
new_platform, new_operator = self._recompute(0, 0, 0)
assert new_platform == 0
assert new_operator == 0
def test_invariant_sum_equals_new_commission(self):
def test_invariant_sum_equals_new_fee(self):
# Random-ish parameter sweep over realistic values.
cases = [
(100, 30, 50),
(100, 0, 50), # original platform_fee was 0 (super_pct=0)
(100, 100, 50), # original platform_fee was 100 (super_pct=100)
(100, 0, 50), # original platform_fee was 0 (super_fraction=0)
(100, 100, 50), # original platform_fee was 100 (super_fraction=100)
(7965, 2390, 3982),
(7965, 7965, 3982),
(1_000_000, 333_333, 250_000),