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

@ -3,52 +3,19 @@ Pure calculation functions for DCA transaction processing.
These functions have no external dependencies (no lnbits, no database)
and can be easily tested in isolation.
What's intentionally NOT here (deleted 2026-05-26):
- `calculate_commission` (back-derive principal+fee from a gross-with-
commission-baked-in wire amount). Lamassu-era reverse-derivation;
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
directly on Payment.extra per aiolabs/lamassu-next#44.
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
`exchange_rate` directly on Payment.extra too. Not used in production.
"""
from typing import Dict, Tuple
def calculate_commission(
crypto_atoms: int,
commission_percentage: float,
discount: float = 0.0
) -> Tuple[int, int, float]:
"""
Calculate commission split from a Lamassu transaction.
The crypto_atoms from Lamassu already includes the commission baked in.
This function extracts the base amount (for DCA distribution) and
commission amount (for commission wallet).
Formula:
effective_commission = commission_percentage * (100 - discount) / 100
base_amount = round(crypto_atoms / (1 + effective_commission))
commission_amount = crypto_atoms - base_amount
Args:
crypto_atoms: Total sats from Lamassu (includes commission)
commission_percentage: Commission rate as decimal (e.g., 0.03 for 3%)
discount: Discount percentage on commission (e.g., 10.0 for 10% off)
Returns:
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
Example:
>>> calculate_commission(266800, 0.03, 0.0)
(259029, 7771, 0.03)
"""
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
return base_crypto_atoms, commission_amount_sats, effective_commission
def calculate_distribution(
base_amount_sats: int,
client_balances: Dict[str, float],
@ -132,14 +99,15 @@ def calculate_distribution(
def split_two_stage_commission(
commission_sats: int, super_fee_pct: float
fee_sats: int, super_fee_fraction: float
) -> Tuple[int, int]:
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the
total commission; the remainder is what the operator's own ruleset acts on.
"""Stage-1 of the v2 commission split: super takes `super_fee_fraction`
of the total fee; the remainder is what the operator's own ruleset
acts on.
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
operator absorbs the rounding remainder so platform_fee + operator_fee
== commission_sats exactly.
== fee_sats exactly.
Examples:
>>> split_two_stage_commission(100, 0.30)
@ -151,23 +119,28 @@ def split_two_stage_commission(
>>> split_two_stage_commission(100, 1.0)
(100, 0)
"""
if commission_sats <= 0:
if not (0.0 <= super_fee_fraction <= 1.0):
raise ValueError(
f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}"
)
if fee_sats <= 0:
return 0, 0
platform = round(commission_sats * super_fee_pct)
platform = max(0, min(platform, commission_sats))
operator = commission_sats - platform
platform = round(fee_sats * super_fee_fraction)
platform = max(0, min(platform, fee_sats))
operator = fee_sats - platform
return platform, operator
def allocate_operator_split_legs(
operator_fee_sats: int, leg_pcts: list
operator_fee_sats: int, leg_fractions: list
) -> list:
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
across N leg wallets per `leg_pcts` (each in 0..1, sum should equal 1.0).
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
equal 1.0).
The last leg absorbs the rounding remainder so the sum of allocations
exactly equals operator_fee_sats (assuming pcts sum to ~1.0). Returns
a list of integer sat amounts in the same order as leg_pcts.
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
Returns a list of integer sat amounts in the same order as leg_fractions.
Examples:
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
@ -179,33 +152,24 @@ def allocate_operator_split_legs(
>>> allocate_operator_split_legs(0, [0.5, 0.5])
[0, 0]
"""
if not leg_pcts:
if not leg_fractions:
return []
if operator_fee_sats <= 0:
return [0] * len(leg_pcts)
return [0] * len(leg_fractions)
for f in leg_fractions:
if not (0.0 <= float(f) <= 1.0):
raise ValueError(
f"every leg fraction must be in [0, 1], got {f}"
)
allocations: list = []
remaining = operator_fee_sats
for idx, pct in enumerate(leg_pcts):
if idx == len(leg_pcts) - 1:
for idx, fraction in enumerate(leg_fractions):
if idx == len(leg_fractions) - 1:
allocations.append(remaining)
else:
amount = round(operator_fee_sats * float(pct))
amount = round(operator_fee_sats * float(fraction))
allocations.append(amount)
remaining -= amount
return allocations
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
"""
Calculate exchange rate in sats per fiat unit.
Args:
base_crypto_atoms: Base amount in sats (after commission)
fiat_amount: Fiat amount dispensed
Returns:
Exchange rate as sats per fiat unit
"""
if fiat_amount <= 0:
return 0.0
return base_crypto_atoms / fiat_amount