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:
parent
6348c55e37
commit
d717a6e214
12 changed files with 530 additions and 681 deletions
108
calculations.py
108
calculations.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue