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

@ -111,32 +111,32 @@ async def _record_skipped_leg(
)
def _resolve_partial_dispense_gross(
def _resolve_partial_dispense_wire(
settlement: DcaSettlement, data: PartialDispenseData
) -> int:
if data.dispensed_sats is not None:
new_gross = int(data.dispensed_sats)
new_wire = int(data.dispensed_sats)
elif data.dispensed_fraction is not None:
new_gross = round(settlement.gross_sats * float(data.dispensed_fraction))
new_wire = round(settlement.wire_sats * float(data.dispensed_fraction))
else:
raise ValueError("provide one of dispensed_sats or dispensed_fraction")
if new_gross < 0:
if new_wire < 0:
raise ValueError("partial dispense cannot be negative")
if new_gross > settlement.gross_sats:
if new_wire > settlement.wire_sats:
raise ValueError(
f"partial dispense ({new_gross} sats) cannot exceed the original "
f"gross ({settlement.gross_sats} sats)"
f"partial dispense ({new_wire} sats) cannot exceed the original "
f"wire amount ({settlement.wire_sats} sats)"
)
return new_gross
return new_wire
def _build_partial_dispense_memo(
settlement: DcaSettlement,
data: PartialDispenseData,
*,
new_gross: int,
new_wire: int,
new_principal: int,
new_commission: int,
new_fee: int,
new_platform: int,
new_operator: int,
) -> str:
@ -148,13 +148,13 @@ def _build_partial_dispense_memo(
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
return (
f"[{ts}] partial dispense applied — {adjust}. "
f"Original gross={settlement.gross_sats} "
f"Original wire={settlement.wire_sats} "
f"principal={settlement.principal_sats} "
f"commission={settlement.commission_sats} "
f"fee={settlement.fee_sats} "
f"(super_fee={settlement.platform_fee_sats} "
f"operator_fee={settlement.operator_fee_sats}). "
f"New gross={new_gross} principal={new_principal} "
f"commission={new_commission} "
f"New wire={new_wire} principal={new_principal} "
f"fee={new_fee} "
f"(super_fee={new_platform} operator_fee={new_operator}). "
f"Reason: {reason}"
)
@ -275,10 +275,10 @@ async def apply_partial_dispense_and_redistribute(
When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after
6 of 10 bills), the operator confirms the actual amount dispensed and we
re-allocate the split against that partial gross. Sat amounts scale
re-allocate the split against that partial wire amount. Sat amounts scale
linearly, preserving the original commission ratio exactly. The two-stage
super/operator split also scales by the *original* platform_fee_sats /
commission_sats ratio rather than re-reading current super_fee_pct
fee_sats ratio rather than re-reading current super_fee_fraction
this honors the "absolute fields are the source of truth" invariant
even when super has changed the global rate since the settlement landed
(closes #11 H6).
@ -296,8 +296,8 @@ async def apply_partial_dispense_and_redistribute(
settlement = await get_settlement(settlement_id)
if settlement is None:
raise ValueError(f"settlement {settlement_id} not found")
if settlement.gross_sats <= 0:
raise ValueError("cannot partial-dispense a zero-gross settlement")
if settlement.wire_sats <= 0:
raise ValueError("cannot partial-dispense a zero-wire settlement")
completed = await count_completed_legs_for_settlement(settlement_id)
if completed > 0:
raise ValueError(
@ -305,33 +305,33 @@ async def apply_partial_dispense_and_redistribute(
"(Lightning payments can't be clawed back)"
)
new_gross = _resolve_partial_dispense_gross(settlement, data)
new_wire = _resolve_partial_dispense_wire(settlement, data)
# Linear scale preserves the original commission ratio exactly.
scale = new_gross / settlement.gross_sats
new_commission = round(settlement.commission_sats * scale)
new_principal = new_gross - new_commission
scale = new_wire / settlement.wire_sats
new_fee = round(settlement.fee_sats * scale)
new_principal = new_wire - new_fee
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
# settlement row — NOT the current super_fee_pct. The contract was
# settlement row — NOT the current super_fee_fraction. The contract was
# locked at landing; super raising or lowering the global rate after
# the fact must not retroactively change this transaction's share.
# Operator absorbs the rounding remainder so platform + operator
# == new_commission exactly.
if settlement.commission_sats > 0:
ratio = settlement.platform_fee_sats / settlement.commission_sats
# == new_fee exactly.
if settlement.fee_sats > 0:
ratio = settlement.platform_fee_sats / settlement.fee_sats
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
memo = _build_partial_dispense_memo(
settlement,
data,
new_gross=new_gross,
new_wire=new_wire,
new_principal=new_principal,
new_commission=new_commission,
new_fee=new_fee,
new_platform=new_platform,
new_operator=new_operator,
)
@ -339,9 +339,9 @@ async def apply_partial_dispense_and_redistribute(
await void_open_legs_for_settlement(settlement_id)
updated = await apply_partial_dispense(
settlement_id,
new_gross_sats=new_gross,
new_wire_sats=new_wire,
new_principal_sats=new_principal,
new_commission_sats=new_commission,
new_fee_sats=new_fee,
new_platform_fee_sats=new_platform,
new_operator_fee_sats=new_operator,
new_fiat_amount=new_fiat,
@ -467,7 +467,7 @@ async def _pay_operator_splits(
# Pure allocator handles the rounding rule (last leg absorbs remainder).
leg_amounts = allocate_operator_split_legs(
settlement.operator_fee_sats,
[float(leg.pct) for leg in splits],
[float(leg.fraction) for leg in splits],
)
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)):
if amount <= 0: