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>
135 lines
4.9 KiB
Python
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"]
|