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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue