From d717a6e2149c707edb90f229bb22920156b8ec45 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 26 May 2026 20:08:30 +0200 Subject: [PATCH] refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bitspire.py | 256 ++++++++++++++++--------- calculations.py | 108 ++++------- crud.py | 41 ++-- distribution.py | 68 +++---- migrations.py | 90 +++++++-- models.py | 60 ++---- static/js/index.js | 42 ++--- tasks.py | 146 ++++++++++----- templates/satmachineadmin/index.html | 60 ++---- tests/test_calculations.py | 268 ++------------------------- tests/test_nostr_attribution.py | 1 - tests/test_two_stage_split.py | 71 +++---- 12 files changed, 530 insertions(+), 681 deletions(-) diff --git a/bitspire.py b/bitspire.py index ac0a4b3..e40c230 100644 --- a/bitspire.py +++ b/bitspire.py @@ -1,24 +1,22 @@ # Satoshi Machine v2 — bitSpire payment parser. # -# Translates an inbound LNbits Payment (cash-out customer paid the ATM's -# invoice) into the principal/commission split needed by satmachineadmin. +# Translates an inbound LNbits Payment into a CreateDcaSettlementData by +# reading the canonical split fields bitSpire stamps on Payment.extra per +# aiolabs/lamassu-next#44 (`source: "bitspire"`, `principal_sats`, +# `fee_sats`, `exchange_rate`, etc.). # -# Happy path: bitSpire populates Payment.extra with the canonical split -# fields per aiolabs/lamassu-next#44 — we read them directly. -# -# Fallback path: extra is missing (older bitSpire, edge case). We back-derive -# the split from the machine's fallback_commission_pct using the Lamassu-era -# formula (base = total / (1 + commission)) and mark used_fallback_split=true -# so the audit trail shows we estimated. +# No back-derivation. If Payment.extra is missing the bitSpire stamp or +# any required field, we raise SettlementMetadataError and the caller +# records the settlement as 'rejected' for upstream investigation — the +# Lamassu-era reverse-derivation from gross-with-commission-baked-in is +# obsolete now that the wire carries principal_sats and fee_sats +# directly. from __future__ import annotations import json -from typing import Any, Optional, Tuple +from typing import Any, Optional -from loguru import logger - -from .calculations import calculate_commission from .models import CreateDcaSettlementData, Machine # Sentinel value bitSpire sets in Payment.extra.source so we know an inbound @@ -74,6 +72,34 @@ class SettlementAttributionError(ValueError): """ +class SettlementInvariantError(ValueError): + """A sat-amount or fee-fraction value violates the cross-codebase + canonical invariants (see + `~/.claude/projects/.../memory/reference_sat_amount_vocabulary.md`). + + Raised by `_assert_sat_invariants`. Caller treats it like + SettlementAttributionError — record as rejected, don't distribute. + A breach means something upstream (bitSpire, the relay, a buggy + consumer) is stamping garbage on Payment.extra; we don't want to + quietly silently distribute against corrupt numbers. + """ + + +class SettlementMetadataError(ValueError): + """Payment.extra is missing the bitSpire stamp or required fields. + + Raised by `parse_settlement`. Caller records the settlement as + 'rejected' with the exception message in `error_message`. Operator + investigates the ATM that issued the invoice — a bitSpire ATM that + landed on a satmachineadmin-managed wallet without stamping the + canonical fields is a real upstream bug (lamassu-next side), not a + graceful-degradation case. Pre-v2 reverse-derivation from the + wire amount + a machine-level fallback rate is no longer supported: + the wire-format contract (lamassu-next#44) is that the ATM always + stamps `principal_sats` and `fee_sats` explicitly. + """ + + def assert_nostr_attribution(machine: Machine, extra: dict) -> None: """Assert that the originating Nostr signer pubkey matches the machine. @@ -111,49 +137,124 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None: ) +def _assert_sat_invariants( + *, + tx_type: str, + wire_sats: int, + principal_sats: int, + fee_sats: int, + fee_fraction: Optional[float] = None, +) -> None: + """Enforce the cross-codebase canonical sat-amount invariants on the + parsed settlement values BEFORE building the `CreateDcaSettlementData`. + + Range invariants (all cases): + - wire_sats, principal_sats, fee_sats are all non-negative integers. + - fee_fraction (if provided) is in [0, 1]. + + Sum invariants (direction-specific): + - cash_out: wire_sats == principal_sats + fee_sats + - cash_in: wire_sats == principal_sats - fee_sats + AND fee_sats <= principal_sats + (commission cannot exceed the principal in a cash-in; + a customer can't owe negative sats) + + The fee_fraction × principal_sats sanity check (≈ fee_sats ±1) is + intentionally NOT enforced here — fee_fraction is informational on + Payment.extra; the absolute fee_sats stamp is the audit anchor and + the source of truth. The two can drift by a few sats due to upstream + rounding without indicating corruption. If we ever observe drift + >1% of fee_sats we'll add the check. + + Raises SettlementInvariantError with a precise message on any breach. + Reference: `reference_sat_amount_vocabulary.md`. + """ + # Range checks + if wire_sats < 0: + raise SettlementInvariantError(f"wire_sats must be >= 0, got {wire_sats}") + if principal_sats < 0: + raise SettlementInvariantError( + f"principal_sats must be >= 0, got {principal_sats}" + ) + if fee_sats < 0: + raise SettlementInvariantError(f"fee_sats must be >= 0, got {fee_sats}") + if fee_fraction is not None and not (0.0 <= fee_fraction <= 1.0): + raise SettlementInvariantError( + f"fee_fraction must be in [0, 1], got {fee_fraction} " + f"(if you see a value >1 the upstream may be stamping percentage " + f"instead of fraction — check lamassu-next#? rename status)" + ) + + # Sum invariants per direction + if tx_type == "cash_out": + expected_wire = principal_sats + fee_sats + if wire_sats != expected_wire: + raise SettlementInvariantError( + f"cash-out wire_sats invariant violated: " + f"wire_sats={wire_sats} != principal_sats({principal_sats}) " + f"+ fee_sats({fee_sats}) = {expected_wire}" + ) + elif tx_type == "cash_in": + if fee_sats > principal_sats: + raise SettlementInvariantError( + f"cash-in fee_sats({fee_sats}) cannot exceed " + f"principal_sats({principal_sats}) — commission > principal " + f"would mean a customer owes negative sats" + ) + expected_wire = principal_sats - fee_sats + if wire_sats != expected_wire: + raise SettlementInvariantError( + f"cash-in wire_sats invariant violated: " + f"wire_sats={wire_sats} != principal_sats({principal_sats}) " + f"- fee_sats({fee_sats}) = {expected_wire}" + ) + else: + raise SettlementInvariantError( + f"unknown tx_type={tx_type!r}; expected 'cash_out' or 'cash_in'" + ) + + def parse_settlement( machine: Machine, payment_hash: str, - gross_sats: int, + wire_sats: int, extra: dict, - super_fee_pct: float, -) -> Tuple[CreateDcaSettlementData, bool]: + super_fee_fraction: float, +) -> CreateDcaSettlementData: """Build a CreateDcaSettlementData for an inbound payment landing on `machine`'s wallet. - Returns (data, used_fallback): when `used_fallback` is True, bitSpire - didn't populate Payment.extra so we back-derived the split. Caller - should log this for visibility — once aiolabs/lamassu-next#44 ships, - fallback usage should drop to zero. + Requires bitSpire's canonical Payment.extra stamp (source="bitspire" + plus the absolute sat amounts) per aiolabs/lamassu-next#44. Raises + `SettlementMetadataError` on missing/partial stamp — caller records + the settlement as 'rejected' for upstream investigation. Raises + `SettlementInvariantError` if the stamped values violate the + canonical sat-amount invariants (range + sum, see + `_assert_sat_invariants`). """ - if is_bitspire_payment(extra): - data = _parse_extra(machine, payment_hash, gross_sats, extra, super_fee_pct) - return data, False - logger.warning( - f"satmachineadmin: settlement on machine {machine.machine_npub[:12]}... " - f"missing bitSpire extra metadata; back-deriving via " - f"fallback_commission_pct={machine.fallback_commission_pct}. " - f"See aiolabs/lamassu-next#44." - ) - return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct), True - - -def _parse_extra( - machine: Machine, - payment_hash: str, - gross_sats: int, - extra: dict, - super_fee_pct: float, -) -> CreateDcaSettlementData: - """Happy path: bitSpire populated Payment.extra per lamassu-next#44.""" + if not (0.0 <= super_fee_fraction <= 1.0): + raise SettlementInvariantError( + f"super_fee_fraction must be in [0, 1], got {super_fee_fraction}" + ) + if not is_bitspire_payment(extra): + raise SettlementMetadataError( + f"Payment.extra missing `source: \"bitspire\"` marker on machine " + f"{machine.machine_npub[:12]}... — invoice did not come through " + f"a bitSpire ATM, or the ATM firmware is older than " + f"aiolabs/lamassu-next#44 and didn't stamp the canonical fields" + ) principal_sats = _coerce_int(extra.get("principal_sats")) fee_sats = _coerce_int(extra.get("fee_sats")) if principal_sats is None or fee_sats is None: - # Missing key fields — shouldn't happen post-#44 but defensive. - return _parse_fallback(machine, payment_hash, gross_sats, super_fee_pct) - commission_sats = fee_sats - platform_fee_sats = round(commission_sats * super_fee_pct) - operator_fee_sats = commission_sats - platform_fee_sats + raise SettlementMetadataError( + f"Payment.extra has source=bitspire but is missing required " + f"fields principal_sats={extra.get('principal_sats')!r} or " + f"fee_sats={extra.get('fee_sats')!r}; the wire-format contract " + f"(lamassu-next#44) requires both. Investigate the ATM " + f"firmware on machine {machine.machine_npub[:12]}..." + ) + platform_fee_sats = round(fee_sats * super_fee_fraction) + operator_fee_sats = fee_sats - platform_fee_sats exchange_rate = _coerce_float(extra.get("exchange_rate")) if exchange_rate is None or exchange_rate <= 0: # Without exchange rate we can't compute fiat. Use 1.0 as a stand-in @@ -163,66 +264,39 @@ def _parse_extra( # dispenser ledger (lamassu-next@8318489). It's the cash that # physically entered (cash-in) or exited (cash-out) the machine — # canonical, not derived. We never recompute it from sats × rate - # downstream: the relationship is't load-bearing (commission lives + # downstream: the relationship isn't load-bearing (commission lives # in BTC today, but the cash side has its own ground truth). fiat_amount = _coerce_float(extra.get("fiat_amount")) or 0.0 fiat_code = _coerce_str(extra.get("currency")) or machine.fiat_code - return CreateDcaSettlementData( + tx_type = _coerce_str(extra.get("type")) or "cash_out" + data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment_hash, bitspire_event_id=None, bitspire_txid=_coerce_str(extra.get("txid")), - gross_sats=gross_sats, + wire_sats=wire_sats, fiat_amount=fiat_amount, fiat_code=fiat_code, exchange_rate=exchange_rate, principal_sats=principal_sats, - commission_sats=commission_sats, + fee_sats=fee_sats, platform_fee_sats=platform_fee_sats, operator_fee_sats=operator_fee_sats, - used_fallback_split=False, - tx_type=_coerce_str(extra.get("type")) or "cash_out", + tx_type=tx_type, bills_json=_json_dumps(extra.get("bills")), cassettes_json=_json_dumps(extra.get("cassettes")), ) - - -def _parse_fallback( - machine: Machine, - payment_hash: str, - gross_sats: int, - super_fee_pct: float, -) -> CreateDcaSettlementData: - """Back-derive the split using the machine's fallback_commission_pct. - - Same formula as the Lamassu integration used: - base_amount = round(gross / (1 + commission_pct)) - commission = gross - base_amount - """ - principal_sats, commission_sats, _effective = calculate_commission( - crypto_atoms=gross_sats, - commission_percentage=machine.fallback_commission_pct, - discount=0.0, - ) - platform_fee_sats = round(commission_sats * super_fee_pct) - operator_fee_sats = commission_sats - platform_fee_sats - # No exchange rate from the wire; leave fiat_amount=0 so it's visibly - # incomplete on the operator's reconciliation screen. - return CreateDcaSettlementData( - machine_id=machine.id, - payment_hash=payment_hash, - bitspire_event_id=None, - bitspire_txid=None, - gross_sats=gross_sats, - fiat_amount=0.0, - fiat_code=machine.fiat_code, - exchange_rate=0.0, - principal_sats=principal_sats, - commission_sats=commission_sats, - platform_fee_sats=platform_fee_sats, - operator_fee_sats=operator_fee_sats, - used_fallback_split=True, - tx_type="cash_out", - bills_json=None, - cassettes_json=None, + # Enforce the cross-codebase canonical sat-amount invariants on the + # values bitSpire stamped (post-rename: `fee_fraction` is preferred; + # the old `fee_percent` field is deliberately NOT read here because + # of the 100× misinterpretation risk during the rename window — the + # absolute `fee_sats` stamp is the audit anchor and the sum + # invariants below catch any garbage at the wire). + _assert_sat_invariants( + tx_type=data.tx_type, + wire_sats=data.wire_sats, + principal_sats=data.principal_sats, + fee_sats=data.fee_sats, + fee_fraction=_coerce_float(extra.get("fee_fraction")), ) + return data diff --git a/calculations.py b/calculations.py index 68e0ae1..f2beb96 100644 --- a/calculations.py +++ b/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 diff --git a/crud.py b/crud.py index 94c42e2..51b07a4 100644 --- a/crud.py +++ b/crud.py @@ -78,10 +78,9 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach """ INSERT INTO satoshimachine.dca_machines (id, operator_user_id, machine_npub, wallet_id, name, location, - fiat_code, is_active, fallback_commission_pct, created_at, updated_at) + fiat_code, is_active, created_at, updated_at) VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name, - :location, :fiat_code, :is_active, :fallback_commission_pct, - :created_at, :updated_at) + :location, :fiat_code, :is_active, :created_at, :updated_at) """, { "id": machine_id, @@ -92,7 +91,6 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach "location": data.location, "fiat_code": data.fiat_code, "is_active": True, - "fallback_commission_pct": data.fallback_commission_pct, "created_at": now, "updated_at": now, }, @@ -555,14 +553,14 @@ async def create_settlement_idempotent( """ INSERT INTO satoshimachine.dca_settlements (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, - gross_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, - commission_sats, platform_fee_sats, operator_fee_sats, - used_fallback_split, tx_type, bills_json, cassettes_json, + wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, + fee_sats, platform_fee_sats, operator_fee_sats, + tx_type, bills_json, cassettes_json, status, error_message, created_at) VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, - :bitspire_txid, :gross_sats, :fiat_amount, :fiat_code, - :exchange_rate, :principal_sats, :commission_sats, - :platform_fee_sats, :operator_fee_sats, :used_fallback_split, + :bitspire_txid, :wire_sats, :fiat_amount, :fiat_code, + :exchange_rate, :principal_sats, :fee_sats, + :platform_fee_sats, :operator_fee_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -572,15 +570,14 @@ async def create_settlement_idempotent( "payment_hash": data.payment_hash, "bitspire_event_id": data.bitspire_event_id, "bitspire_txid": data.bitspire_txid, - "gross_sats": data.gross_sats, + "wire_sats": data.wire_sats, "fiat_amount": data.fiat_amount, "fiat_code": data.fiat_code, "exchange_rate": data.exchange_rate, "principal_sats": data.principal_sats, - "commission_sats": data.commission_sats, + "fee_sats": data.fee_sats, "platform_fee_sats": data.platform_fee_sats, "operator_fee_sats": data.operator_fee_sats, - "used_fallback_split": data.used_fallback_split, "tx_type": data.tx_type, "bills_json": data.bills_json, "cassettes_json": data.cassettes_json, @@ -840,9 +837,9 @@ async def reset_settlement_for_retry( async def apply_partial_dispense( settlement_id: str, *, - new_gross_sats: int, + new_wire_sats: int, new_principal_sats: int, - new_commission_sats: int, + new_fee_sats: int, new_platform_fee_sats: int, new_operator_fee_sats: int, new_fiat_amount: float, @@ -858,9 +855,9 @@ async def apply_partial_dispense( await db.execute( """ UPDATE satoshimachine.dca_settlements - SET gross_sats = :gross, + SET wire_sats = :gross, principal_sats = :principal, - commission_sats = :commission, + fee_sats = :commission, platform_fee_sats = :platform, operator_fee_sats = :operator, fiat_amount = :fiat, @@ -875,9 +872,9 @@ async def apply_partial_dispense( """, { "id": settlement_id, - "gross": new_gross_sats, + "gross": new_wire_sats, "principal": new_principal_sats, - "commission": new_commission_sats, + "commission": new_fee_sats, "platform": new_platform_fee_sats, "operator": new_operator_fee_sats, "fiat": new_fiat_amount, @@ -1013,9 +1010,9 @@ async def replace_commission_splits( await db.execute( """ INSERT INTO satoshimachine.dca_commission_splits - (id, machine_id, operator_user_id, target, label, pct, + (id, machine_id, operator_user_id, target, label, fraction, sort_order, created_at) - VALUES (:id, :machine_id, :uid, :target, :label, :pct, + VALUES (:id, :machine_id, :uid, :target, :label, :fraction, :sort_order, :created_at) """, { @@ -1024,7 +1021,7 @@ async def replace_commission_splits( "uid": operator_user_id, "target": leg.target, "label": leg.label, - "pct": leg.pct, + "fraction": leg.fraction, "sort_order": leg.sort_order, "created_at": now, }, diff --git a/distribution.py b/distribution.py index d1e5fee..fc94f9c 100644 --- a/distribution.py +++ b/distribution.py @@ -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: diff --git a/migrations.py b/migrations.py index 508aa51..38b29d0 100644 --- a/migrations.py +++ b/migrations.py @@ -62,7 +62,7 @@ async def m001_satmachine_v2_initial(db): await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.super_config ( id TEXT PRIMARY KEY, - super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + super_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, super_fee_wallet_id TEXT, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); @@ -72,7 +72,7 @@ async def m001_satmachine_v2_initial(db): ) if not existing: await db.execute( - "INSERT INTO satoshimachine.super_config (id, super_fee_pct) " + "INSERT INTO satoshimachine.super_config (id, super_fee_fraction) " "VALUES ('default', 0.0000)" ) @@ -88,7 +88,6 @@ async def m001_satmachine_v2_initial(db): location TEXT, fiat_code TEXT NOT NULL DEFAULT 'GTQ', is_active BOOLEAN NOT NULL DEFAULT true, - fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); @@ -180,10 +179,10 @@ async def m001_satmachine_v2_initial(db): # append-only audit memo for partial-dispense + operator notes. # # platform_fee_sats and operator_fee_sats are absolute BIGINT, - # NOT derived percentages — when the v2 customer-discount engine + # NOT derived fractions — when the v2 customer-discount engine # ships, these two columns are the audit-grade record of who # forgave what per transaction. Do not collapse them into a single - # commission_pct. See plan section "Customer discounts" and #10. + # fee_fraction. See plan section "Customer discounts" and #10. await db.execute(f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_settlements ( id TEXT PRIMARY KEY, @@ -191,15 +190,14 @@ async def m001_satmachine_v2_initial(db): payment_hash TEXT NOT NULL UNIQUE, bitspire_event_id TEXT, bitspire_txid TEXT, - gross_sats BIGINT NOT NULL, + wire_sats BIGINT NOT NULL, fiat_amount DECIMAL(10,2) NOT NULL, fiat_code TEXT NOT NULL DEFAULT 'GTQ', exchange_rate REAL NOT NULL, principal_sats BIGINT NOT NULL, - commission_sats BIGINT NOT NULL, + fee_sats BIGINT NOT NULL, platform_fee_sats BIGINT NOT NULL, operator_fee_sats BIGINT NOT NULL, - used_fallback_split BOOLEAN NOT NULL DEFAULT false, tx_type TEXT NOT NULL, bills_json TEXT, cassettes_json TEXT, @@ -217,9 +215,9 @@ async def m001_satmachine_v2_initial(db): ) # 7. dca_commission_splits — operator's rules for distributing the - # *remainder* (commission_sats - platform_fee_sats). One row per + # *remainder* (fee_sats - platform_fee_sats). One row per # leg. machine_id=NULL = operator default; non-null = per-machine - # override. Sum(pct) per (operator, machine) must equal 1.0 — + # override. Sum(fraction) per (operator, machine) must equal 1.0 — # enforced at write-time in crud.py. # # `target` accepts any of (splitpayments-style): @@ -235,7 +233,7 @@ async def m001_satmachine_v2_initial(db): operator_user_id TEXT NOT NULL, target TEXT NOT NULL, label TEXT, - pct DECIMAL(10,4) NOT NULL, + fraction DECIMAL(10,4) NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); @@ -439,6 +437,76 @@ async def m004_introduce_dca_lp_table(db): await db.execute(f"ALTER TABLE satoshimachine.dca_clients DROP COLUMN {col}") +async def m006_rename_to_canonical_sat_vocabulary(db): + """Adopt the cross-codebase canonical sat-amount vocabulary AND drop + the now-obsolete Lamassu-era fallback columns, per the decision at + memory `reference_sat_amount_vocabulary.md` (2026-05-26): + + Renames: + - 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 + + Drops (Lamassu-era reverse-derivation is obsolete since bitSpire + stamps both `principal_sats` AND `fee_sats` directly on + Payment.extra per lamassu-next#44 — there's nothing to back-derive): + - dca_machines.fallback_commission_pct (was the rate used by the + deleted `_parse_fallback` path) + - dca_settlements.used_fallback_split (was the per-row marker for + that path) + + Same canonical applies on the lamassu-next + atm-tui side; the + rename is coordinated via `~/dev/coordination/log.md` (2026-05-26). + + Each step is idempotent — probe for the OLD column; rename/drop only + if present; otherwise no-op (covers fresh installs where m001 + already laid down the canonical schema). + + Why a single migration: all driven by the same decision and any + external code wants to see the whole rename + cleanup land at once. + """ + renames = [ + ("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"), + ] + for table, old_col, new_col in renames: + try: + await db.fetchone( + f"SELECT {old_col} FROM satoshimachine.{table} LIMIT 1" + ) + except Exception: + # old column doesn't exist; either rename already landed or + # m001 produced the canonical schema directly on fresh install. + continue + await db.execute( + f"ALTER TABLE satoshimachine.{table} " + f"RENAME COLUMN {old_col} TO {new_col}" + ) + + # Drop the Lamassu-era fallback columns. Same idempotency pattern. + # Try both old (_pct) and new (_fraction) names for the dca_machines + # column since an install could be at either rename state. + drops = [ + ("dca_machines", "fallback_commission_pct"), + ("dca_machines", "fallback_commission_fraction"), + ("dca_settlements", "used_fallback_split"), + ] + for table, col in drops: + try: + await db.fetchone( + f"SELECT {col} FROM satoshimachine.{table} LIMIT 1" + ) + except Exception: + # column doesn't exist; either already dropped or never present. + continue + await db.execute( + f"ALTER TABLE satoshimachine.{table} DROP COLUMN {col}" + ) + + async def m005_lock_deposit_currency_to_machine_fiat_code(db): """Rewrite every `dca_deposits.currency` row to match its joined `dca_machines.fiat_code`. diff --git a/models.py b/models.py index c61a515..d683cac 100644 --- a/models.py +++ b/models.py @@ -29,18 +29,6 @@ class CreateMachineData(BaseModel): name: Optional[str] = None location: Optional[str] = None fiat_code: str = "GTQ" - # Used only when bitSpire's settlement event omits principal_sats/ - # fee_sats in Payment.extra (older bitSpire or edge cases). See - # plan's lamassu-next informational issue #1. - fallback_commission_pct: float = 0.05 - - @validator("fallback_commission_pct") - def commission_in_unit_range(cls, v): - if v is None: - return v - if v < 0 or v > 1: - raise ValueError("fallback_commission_pct must be between 0 and 1") - return round(float(v), 4) class Machine(BaseModel): @@ -52,7 +40,6 @@ class Machine(BaseModel): location: Optional[str] fiat_code: str is_active: bool - fallback_commission_pct: float created_at: datetime updated_at: datetime @@ -63,15 +50,6 @@ class UpdateMachineData(BaseModel): fiat_code: Optional[str] = None is_active: Optional[bool] = None wallet_id: Optional[str] = None - fallback_commission_pct: Optional[float] = None - - @validator("fallback_commission_pct") - def commission_in_unit_range(cls, v): - if v is None: - return v - if v < 0 or v > 1: - raise ValueError("fallback_commission_pct must be between 0 and 1") - return round(float(v), 4) # ============================================================================= @@ -225,7 +203,7 @@ class UpdateDepositStatusData(BaseModel): # ============================================================================= # platform_fee_sats and operator_fee_sats are absolute audit-grade values. # Today they equal the contractual split; tomorrow (post-v1 promo engine) -# they record who-forgave-what. DO NOT collapse them into a single pct. +# they record who-forgave-what. DO NOT collapse them into a single fraction. # See plan section "Customer discounts & promotions (post-v1)". @@ -234,15 +212,14 @@ class CreateDcaSettlementData(BaseModel): payment_hash: str # the idempotency key (UNIQUE in the dca_settlements table) bitspire_event_id: Optional[str] = None # reserved for direct-Nostr ingestion bitspire_txid: Optional[str] = None - gross_sats: int + wire_sats: int fiat_amount: float fiat_code: str = "GTQ" exchange_rate: float principal_sats: int - commission_sats: int + fee_sats: int platform_fee_sats: int operator_fee_sats: int - used_fallback_split: bool = False tx_type: str # 'cash_out' | 'cash_in' bills_json: Optional[str] = None cassettes_json: Optional[str] = None @@ -254,15 +231,14 @@ class DcaSettlement(BaseModel): payment_hash: str bitspire_event_id: Optional[str] bitspire_txid: Optional[str] - gross_sats: int + wire_sats: int fiat_amount: float fiat_code: str exchange_rate: float principal_sats: int - commission_sats: int + fee_sats: int platform_fee_sats: int operator_fee_sats: int - used_fallback_split: bool tx_type: str bills_json: Optional[str] cassettes_json: Optional[str] @@ -295,8 +271,8 @@ class DcaSettlement(BaseModel): # Commission splits — operator-defined remainder allocation per machine. # ============================================================================= # machine_id=NULL means operator's default; non-null means per-machine override. -# Sum of pct across rows for a (operator_user_id, machine_id) scope must be 1.0, -# enforced at write-time in crud.py. +# Sum of fraction across rows for a (operator_user_id, machine_id) scope must +# be 1.0, enforced at write-time in crud.py. class CommissionSplitLeg(BaseModel): @@ -311,7 +287,7 @@ class CommissionSplitLeg(BaseModel): target: str label: Optional[str] = None - pct: float + fraction: float sort_order: int = 0 @validator("target") @@ -321,10 +297,10 @@ class CommissionSplitLeg(BaseModel): raise ValueError("target cannot be empty") return v - @validator("pct") - def pct_in_unit_range(cls, v): + @validator("fraction") + def fraction_in_unit_range(cls, v): if v < 0 or v > 1: - raise ValueError("pct must be between 0 and 1") + raise ValueError("fraction must be between 0 and 1") return round(float(v), 4) @@ -334,7 +310,7 @@ class CommissionSplit(BaseModel): operator_user_id: str target: str label: Optional[str] - pct: float + fraction: float sort_order: int created_at: datetime @@ -351,9 +327,9 @@ class SetCommissionSplitsData(BaseModel): @validator("legs") def legs_sum_to_one(cls, v): - total = round(sum(leg.pct for leg in v), 4) + total = round(sum(leg.fraction for leg in v), 4) if abs(total - 1.0) > 0.0001: - raise ValueError(f"split percentages must sum to 1.0 (got {total})") + raise ValueError(f"split fractions must sum to 1.0 (got {total})") return v @@ -440,21 +416,21 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str - super_fee_pct: float + super_fee_fraction: float super_fee_wallet_id: Optional[str] updated_at: datetime class UpdateSuperConfigData(BaseModel): - super_fee_pct: Optional[float] = None + super_fee_fraction: Optional[float] = None super_fee_wallet_id: Optional[str] = None - @validator("super_fee_pct") + @validator("super_fee_fraction") def fee_in_unit_range(cls, v): if v is None: return v if v < 0 or v > 1: - raise ValueError("super_fee_pct must be between 0 and 1") + raise ValueError("super_fee_fraction must be between 0 and 1") return round(float(v), 4) diff --git a/static/js/index.js b/static/js/index.js index f67aa46..0d70405 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -82,7 +82,7 @@ window.app = Vue.createApp({ columns: [ {name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'}, {name: 'created_at', label: 'Created', field: 'created_at', align: 'left'}, - {name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'}, + {name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'}, { name: 'error_message', label: 'Error', @@ -100,7 +100,7 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: {super_fee_pct: 0, super_fee_wallet_id: ''} + data: {super_fee_fraction: 0, super_fee_wallet_id: ''} }, // UI configuration ----------------------------------------------- @@ -111,12 +111,6 @@ window.app = Vue.createApp({ {name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'}, {name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'}, {name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'}, - { - name: 'fallback_commission_pct', - label: 'Fallback %', - field: 'fallback_commission_pct', - align: 'right' - }, {name: 'actions', label: 'Actions', field: 'id', align: 'right'} ], pagination: {rowsPerPage: 10, sortBy: 'name'} @@ -166,12 +160,12 @@ window.app = Vue.createApp({ columns: [ {name: 'status', label: 'Status', field: 'status', align: 'left'}, {name: 'created_at', label: 'Time', field: 'created_at', align: 'left'}, - {name: 'gross_sats', label: 'Gross', field: 'gross_sats', align: 'right'}, + {name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'}, {name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'}, { - name: 'commission_sats', - label: 'Commission', - field: 'commission_sats', + name: 'fee_sats', + label: 'Fee', + field: 'fee_sats', align: 'right' }, {name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'}, @@ -332,7 +326,7 @@ window.app = Vue.createApp({ }, commissionSum() { return this.commissionLegs.reduce( - (acc, leg) => acc + (Number(leg.pct) || 0), 0 + (acc, leg) => acc + (Number(leg.fraction) || 0), 0 ) }, commissionSumValid() { @@ -351,7 +345,7 @@ window.app = Vue.createApp({ if (idx === this.commissionLegs.length - 1) { sats = remaining } else { - sats = Math.round(total * (Number(leg.pct) || 0)) + sats = Math.round(total * (Number(leg.fraction) || 0)) remaining -= sats } out.push({label: leg.label, sats}) @@ -531,7 +525,7 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- openSuperFeeDialog() { this.superFeeDialog.data = { - super_fee_pct: this.superConfig?.super_fee_pct ?? 0, + super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0, super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true @@ -544,7 +538,7 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'PUT', SUPER_FEE_PATH, null, { - super_fee_pct: Number(d.super_fee_pct), + super_fee_fraction: Number(d.super_fee_fraction), super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) @@ -565,7 +559,7 @@ window.app = Vue.createApp({ this._downloadCsv( 'machines.csv', ['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code', - 'is_active', 'fallback_commission_pct', 'created_at'], + 'is_active', 'created_at'], this.machines ) }, @@ -687,7 +681,6 @@ window.app = Vue.createApp({ location: machine.location || '', wallet_id: machine.wallet_id, fiat_code: machine.fiat_code, - fallback_commission_pct: machine.fallback_commission_pct, is_active: machine.is_active } this.editMachineDialog.show = true @@ -706,7 +699,6 @@ window.app = Vue.createApp({ location: d.location, wallet_id: d.wallet_id, fiat_code: d.fiat_code, - fallback_commission_pct: d.fallback_commission_pct, is_active: d.is_active } ) @@ -1118,7 +1110,7 @@ window.app = Vue.createApp({ target: leg.target || '', targetKind: this._inferTargetKind(leg.target), label: leg.label || '', - pct: Number(leg.pct) || 0 + fraction: Number(leg.fraction) || 0 })) } catch (e) { this.commissionLegs = [] @@ -1140,7 +1132,7 @@ window.app = Vue.createApp({ target: this.walletOptions[0]?.value || '', targetKind: 'wallet', label: '', - pct: 0 + fraction: 0 }) }, @@ -1157,7 +1149,7 @@ window.app = Vue.createApp({ legs: this.commissionLegs.map((leg, idx) => ({ target: (leg.target || '').toString().trim(), label: leg.label || null, - pct: Number(leg.pct), + fraction: Number(leg.fraction), sort_order: idx })) } @@ -1330,8 +1322,7 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ', - fallback_commission_pct: 0.05 + fiat_code: 'GTQ' } }, @@ -1341,8 +1332,7 @@ window.app = Vue.createApp({ wallet_id: d.wallet_id, name: (d.name || '').trim() || null, location: (d.location || '').trim() || null, - fiat_code: (d.fiat_code || 'GTQ').trim(), - fallback_commission_pct: Number(d.fallback_commission_pct ?? 0.05) + fiat_code: (d.fiat_code || 'GTQ').trim() } }, diff --git a/tasks.py b/tasks.py index d19a95d..6e0e8cb 100644 --- a/tasks.py +++ b/tasks.py @@ -3,14 +3,26 @@ # Subscribes to LNbits' invoice dispatcher (register_invoice_listener), then # for each successful inbound payment: # 1. Checks if wallet_id belongs to an active dca_machines row. If not, skip. -# 2. Parses Payment.extra for bitSpire split metadata (post-lamassu-next#44). -# Falls back to machine.fallback_commission_pct if extra is absent. -# 3. Computes the two-stage split (super_fee first, operator remainder). -# 4. Inserts a dca_settlements row idempotently (keyed by payment_hash). -# 5. Spawns the distribution processor on a background task so the +# 2. Verifies the originating Nostr signer matches the machine identity +# (assert_nostr_attribution; uses Payment.extra.nostr_sender_pubkey +# stamped by lnbits nostr-transport dispatcher). +# 3. Parses Payment.extra for bitSpire's canonical split stamp per +# aiolabs/lamassu-next#44 (`source: "bitspire"`, principal_sats, +# fee_sats, exchange_rate). Raises if the stamp is missing or +# garbage (no more Lamassu-era reverse-derivation fallback). +# 4. Computes the two-stage split (super_fee first, operator remainder). +# 5. Inserts a dca_settlements row idempotently (keyed by payment_hash). +# 6. Spawns the distribution processor on a background task so the # LNbits invoice queue (which serves ALL extensions on the node) # keeps draining while we move sats. Concurrency is safe because # process_settlement now uses an optimistic-lock claim (fix bundle 1). +# +# Rejection paths (settlement still recorded with status='rejected' for +# operator forensics, but distribution is skipped): +# - SettlementAttributionError: signer mismatch (G5). +# - SettlementMetadataError: Payment.extra missing bitSpire stamp. +# - SettlementInvariantError: stamped values violate the canonical +# sat-amount invariants (range/sum). import asyncio @@ -20,6 +32,8 @@ from loguru import logger from .bitspire import ( SettlementAttributionError, + SettlementInvariantError, + SettlementMetadataError, assert_nostr_attribution, parse_settlement, ) @@ -29,6 +43,7 @@ from .crud import ( get_super_config, ) from .distribution import process_settlement +from .models import CreateDcaSettlementData, Machine LISTENER_NAME = "ext_satmachineadmin" @@ -64,48 +79,47 @@ async def _handle_payment(payment: Payment) -> None: if machine is None: return extra = payment.extra or {} + + # 1) Attribution FIRST — uses only `extra.nostr_sender_pubkey` (no parse + # needed). If this fails, every subsequent field on `extra` is + # attacker-controlled and untrustworthy — record a minimal rejected + # row with placeholder zeros (don't display unverified split numbers + # in the operator dashboard). + try: + assert_nostr_attribution(machine, extra) + except SettlementAttributionError as exc: + await _record_rejected(payment, machine, exc) + return + + # 2) Parse + invariants. parse_settlement enforces the canonical + # sat-amount invariants on the bitSpire-stamped numbers (range + + # direction-specific sum). Raises SettlementMetadataError if the + # stamp is missing, SettlementInvariantError on any range/sum + # breach. super_config = await get_super_config() - super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 - data, used_fallback = parse_settlement( - machine=machine, - payment_hash=payment.payment_hash, - gross_sats=payment.sat, - extra=extra, - super_fee_pct=super_fee_pct, + super_fee_fraction = ( + float(super_config.super_fee_fraction) if super_config else 0.0 ) + try: + data = parse_settlement( + machine=machine, + payment_hash=payment.payment_hash, + wire_sats=payment.sat, + extra=extra, + super_fee_fraction=super_fee_fraction, + ) + except (SettlementMetadataError, SettlementInvariantError) as exc: + await _record_rejected(payment, machine, exc) + return + # Stamp the originating Nostr event id (the kind-21000 create_invoice - # RPC) onto the row for post-hoc forensics — pairs with the - # assert_nostr_attribution check below so an auditor can trace - # settlement -> RPC event -> signing key without trusting our DB. + # RPC) onto the row for post-hoc forensics — an auditor can trace + # settlement → RPC event → signing key without trusting our DB. nostr_event_id = extra.get("nostr_event_id") if isinstance(nostr_event_id, str) and nostr_event_id: data.bitspire_event_id = nostr_event_id - # Cross-check the signature-verified signer pubkey (stamped by - # LNbits' nostr-transport dispatcher onto Payment.extra) against - # the machine identity. Routing today is wallet_id-only with no - # cryptographic binding — this restores end-to-end attribution - # between "the npub that asked LNbits for the invoice" and "the - # machine we're crediting" (aiolabs/satmachineadmin#19, G5). - try: - assert_nostr_attribution(machine, extra) - except SettlementAttributionError as exc: - rejected = await create_settlement_idempotent( - data, initial_status="rejected", error_message=str(exc) - ) - if rejected is None: - logger.error( - f"satmachineadmin: failed to insert rejected settlement for " - f"payment_hash={payment.payment_hash[:12]}..." - ) - return - logger.error( - f"satmachineadmin: rejected settlement {rejected.id} " - f"(machine={machine.machine_npub[:12]}..., " - f"payment_hash={payment.payment_hash[:12]}...): {exc}" - ) - return - + # 3) Insert + distribute. settlement = await create_settlement_idempotent(data, initial_status="pending") if settlement is None: logger.error( @@ -113,14 +127,13 @@ async def _handle_payment(payment: Payment) -> None: f"payment_hash={payment.payment_hash[:12]}..." ) return - fb = " (fallback split)" if used_fallback else "" logger.info( f"satmachineadmin: landed settlement {settlement.id} for " f"machine={machine.machine_npub[:12]}... " - f"gross={data.gross_sats}sats principal={data.principal_sats}sats " - f"commission={data.commission_sats}sats " + f"wire={data.wire_sats}sats principal={data.principal_sats}sats " + f"fee={data.fee_sats}sats " f"(super_fee={data.platform_fee_sats} " - f"operator_fee={data.operator_fee_sats}){fb}" + f"operator_fee={data.operator_fee_sats})" ) # Spawn distribution on a background task so the LNbits invoice queue # (shared across all extensions) keeps draining while we move sats. @@ -131,3 +144,48 @@ async def _handle_payment(payment: Payment) -> None: task = asyncio.create_task(process_settlement(settlement.id)) _inflight_distributions.add(task) task.add_done_callback(_inflight_distributions.discard) + + +async def _record_rejected( + payment: Payment, machine: Machine, exc: Exception +) -> None: + """Insert a minimal `dca_settlements` row with `status='rejected'` and + the exception message for operator forensics. + + Used for every rejection path (attribution / metadata / invariant). + The split fields are zero placeholders — we deliberately do NOT + display attacker-supplied numbers in the operator dashboard. The + wire amount (`payment.sat`) is the only value LNbits authenticated; + everything else from Payment.extra is untrusted in this branch. + """ + data = CreateDcaSettlementData( + machine_id=machine.id, + payment_hash=payment.payment_hash, + wire_sats=payment.sat, + fiat_amount=0.0, + fiat_code=machine.fiat_code, + exchange_rate=0.0, + principal_sats=0, + fee_sats=0, + platform_fee_sats=0, + operator_fee_sats=0, + # tx_type is unknown for rejection paths; default to cash_out + # (the only direction currently wired). When S8 lands the + # listener will branch on tx_type from extra, and this default + # gets revisited. + tx_type="cash_out", + ) + rejected = await create_settlement_idempotent( + data, initial_status="rejected", error_message=str(exc) + ) + if rejected is None: + logger.error( + f"satmachineadmin: failed to insert rejected settlement for " + f"payment_hash={payment.payment_hash[:12]}..." + ) + return + logger.error( + f"satmachineadmin: rejected settlement {rejected.id} " + f"(machine={machine.machine_npub[:12]}..., " + f"payment_hash={payment.payment_hash[:12]}...): {exc}" + ) diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 0886776..4cf80c6 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,13 +31,13 @@ + :class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> LNbits platform fee: - ${ (superConfig.super_fee_pct * 100).toFixed(2) }% + ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% of each transaction's commission. @@ -143,12 +143,6 @@ v-text="shortId(props.row.wallet_id)"> - - - - Used only when bitSpire doesn't supply a per-tx split. - -
-
@@ -630,8 +624,8 @@
- - + + - - Location -
-
- Fallback commission % -
- -
@@ -893,27 +873,19 @@ - - - Fallback split — bitSpire didn't supply per-tx - net/fee. See lamassu-next#44. - - - - + + - - + +
super @@ -996,7 +968,7 @@ Original gross: - . + . Provide what was actually dispensed. Sat amounts will scale linearly, the commission split will recompute, and distribution will re-run. @@ -1019,7 +991,7 @@ label="Dispensed sats" hint="Exact sat amount actually dispensed (≤ original gross)" type="number" step="1" min="0" - :max="partialDispenseDialog.settlement.gross_sats" + :max="partialDispenseDialog.settlement.wire_sats" dense outlined> @@ -1085,7 +1057,7 @@ Operators see this as a read-only banner. Wallet ID is where the collected fee lands; typically a wallet you (the super) own.

- - 0 - - def test_100_percent_discount(self): - """100% discount should result in zero commission.""" - base, commission, effective = calculate_commission(100000, 0.03, 100.0) - - assert effective == 0.0 - assert commission == 0 - assert base == 100000 - def test_many_clients_distribution(self): """Test distribution with many clients.""" # 10 clients with varying balances diff --git a/tests/test_nostr_attribution.py b/tests/test_nostr_attribution.py index 84877de..34eec29 100644 --- a/tests/test_nostr_attribution.py +++ b/tests/test_nostr_attribution.py @@ -37,7 +37,6 @@ def _machine(npub: str) -> Machine: location=None, fiat_code="EUR", is_active=True, - fallback_commission_pct=0.05, created_at=now, updated_at=now, ) diff --git a/tests/test_two_stage_split.py b/tests/test_two_stage_split.py index beff376..d48cb07 100644 --- a/tests/test_two_stage_split.py +++ b/tests/test_two_stage_split.py @@ -2,11 +2,12 @@ Tests for the v2 two-stage commission split (super first, operator remainder). The plan calls out a verification scenario explicitly: - super_fee_pct=30%, operator split 50/30/20 on a 100-sat commission - → super_wallet gets 30, operator_self gets 35, employee 21, maint 14. + super_fee_fraction=0.30 (i.e. 30%), operator splits [0.5, 0.3, 0.2] on a + 100-sat fee → super_wallet gets 30, operator legs get 35 / 21 / 14. -Also covers the edge cases: super_fee_pct=0 (no super), super_fee_pct=1.0 -(everything to super), single-leg operator ruleset, zero operator fee. +Also covers the edge cases: super_fee_fraction=0.0 (no super takes the +whole fee), super_fee_fraction=1.0 (super takes everything), single-leg +operator ruleset, zero operator fee. """ import pytest @@ -18,7 +19,7 @@ from ..calculations import ( class TestSplitTwoStageCommission: - """Stage-1: super takes super_fee_pct of commission; operator gets rest.""" + """Stage-1: super takes super_fee_fraction of the fee; operator gets rest.""" def test_plan_example_100sats_30pct(self): platform, operator = split_two_stage_commission(100, 0.30) @@ -33,12 +34,12 @@ class TestSplitTwoStageCommission: assert operator == 5575 # 7965 - 2390 assert platform + operator == 7965 - def test_super_pct_zero_leaves_all_to_operator(self): + def test_super_fraction_zero_leaves_all_to_operator(self): platform, operator = split_two_stage_commission(7965, 0.0) assert platform == 0 assert operator == 7965 - def test_super_pct_one_takes_everything(self): + def test_super_fraction_one_takes_everything(self): platform, operator = split_two_stage_commission(7965, 1.0) assert platform == 7965 assert operator == 0 @@ -54,13 +55,13 @@ class TestSplitTwoStageCommission: assert platform == 0 assert operator == 0 - @pytest.mark.parametrize("commission_sats", [1, 7, 100, 7965, 1_000_000]) - @pytest.mark.parametrize("super_pct", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0]) - def test_invariant_sum_equals_commission(self, commission_sats, super_pct): - platform, operator = split_two_stage_commission(commission_sats, super_pct) - assert platform + operator == commission_sats - assert 0 <= platform <= commission_sats - assert 0 <= operator <= commission_sats + @pytest.mark.parametrize("fee_sats", [1, 7, 100, 7965, 1_000_000]) + @pytest.mark.parametrize("super_fraction", [0.0, 0.1, 0.30, 0.5, 0.777, 1.0]) + def test_invariant_sum_equals_commission(self, fee_sats, super_fraction): + platform, operator = split_two_stage_commission(fee_sats, super_fraction) + assert platform + operator == fee_sats + assert 0 <= platform <= fee_sats + assert 0 <= operator <= fee_sats class TestAllocateOperatorSplitLegs: @@ -102,7 +103,7 @@ class TestAllocateOperatorSplitLegs: assert amounts[2] == 100 - amounts[0] - amounts[1] @pytest.mark.parametrize( - "operator_fee,pcts", + "operator_fee,fractions", [ (1, [0.5, 0.5]), (7, [0.5, 0.3, 0.2]), @@ -111,8 +112,8 @@ class TestAllocateOperatorSplitLegs: (1_000_000, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]), ], ) - def test_invariant_sum_equals_operator_fee(self, operator_fee, pcts): - amounts = allocate_operator_split_legs(operator_fee, pcts) + def test_invariant_sum_equals_operator_fee(self, operator_fee, fractions): + amounts = allocate_operator_split_legs(operator_fee, fractions) assert sum(amounts) == operator_fee assert all(a >= 0 for a in amounts) @@ -121,21 +122,21 @@ class TestEndToEndScenarios: """The full two-stage split — super then operator legs — composed.""" def test_plan_example_full(self): - # 100 sats commission, super=30%, operator splits 50/30/20. + # 100 sats fee, super_fee_fraction=0.30, operator splits [0.5, 0.3, 0.2]. platform, operator = split_two_stage_commission(100, 0.30) legs = allocate_operator_split_legs(operator, [0.5, 0.3, 0.2]) assert platform == 30 assert legs == [35, 21, 14] assert platform + sum(legs) == 100 - def test_super_pct_zero_full_pipeline(self): + def test_super_fraction_zero_full_pipeline(self): platform, operator = split_two_stage_commission(7965, 0.0) legs = allocate_operator_split_legs(operator, [1.0]) assert platform == 0 assert legs == [7965] assert platform + sum(legs) == 7965 - def test_super_pct_one_full_pipeline(self): + def test_super_fraction_one_full_pipeline(self): platform, operator = split_two_stage_commission(7965, 1.0) legs = allocate_operator_split_legs(operator, [0.5, 0.5]) assert platform == 7965 @@ -147,27 +148,27 @@ class TestEndToEndScenarios: class TestPartialDispenseSplitRatio: """The partial-dispense recompute (H6 fix) must preserve the ORIGINAL platform/operator ratio from the landed settlement — NOT re-derive - from the current super_fee_pct. + from the current super_fee_fraction. These tests cover the math; the actual function lives in distribution.py and is exercised end-to-end via integration testing. Here we verify the invariant a future maintainer should never break. """ - def _recompute(self, original_commission, original_platform_fee, new_commission): + def _recompute(self, original_fee, original_platform_fee, new_fee): """Mirror of the ratio math in apply_partial_dispense_and_redistribute.""" - if original_commission > 0: - ratio = original_platform_fee / original_commission + if original_fee > 0: + ratio = original_platform_fee / original_fee 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 return new_platform, new_operator def test_plan_scenario_30pct_lands_then_partial(self): - # Landed at super_fee_pct=30%: 100-sat commission → 30 / 70. - # Partial-dispense to 50% gross → new_commission = 50. + # Landed at super_fee_fraction=0.30: 100-sat fee → 30 / 70. + # Partial-dispense to 50% gross → new_fee = 50. # Original ratio (30/100 = 0.30) preserved. new_platform, new_operator = self._recompute(100, 30, 50) assert new_platform == 15 @@ -175,9 +176,9 @@ class TestPartialDispenseSplitRatio: assert new_platform + new_operator == 50 def test_super_changed_rate_doesnt_affect_existing_settlement(self): - # Landed at super_fee_pct=30% (commission 7965, platform 2390). + # Landed at super_fee_fraction=0.30 (fee 7965, platform 2390). # Super then raises rate to 50% globally. Operator partial-dispenses - # to 50% gross → new_commission = 3982 (round(7965 * 0.5)). + # to 50% gross → new_fee = 3982 (round(7965 * 0.5)). # Original ratio (2390/7965 ≈ 0.30) MUST still apply, not 50%. new_platform, new_operator = self._recompute(7965, 2390, 3982) # Expected with original ratio: round(3982 * 0.30006...) = 1195 @@ -187,17 +188,17 @@ class TestPartialDispenseSplitRatio: # Original platform share was ~30%; preserved within rounding. assert abs(new_platform / 3982 - 2390 / 7965) < 0.001 - def test_zero_original_commission_yields_zero_platform(self): + def test_zero_original_fee_yields_zero_platform(self): new_platform, new_operator = self._recompute(0, 0, 0) assert new_platform == 0 assert new_operator == 0 - def test_invariant_sum_equals_new_commission(self): + def test_invariant_sum_equals_new_fee(self): # Random-ish parameter sweep over realistic values. cases = [ (100, 30, 50), - (100, 0, 50), # original platform_fee was 0 (super_pct=0) - (100, 100, 50), # original platform_fee was 100 (super_pct=100) + (100, 0, 50), # original platform_fee was 0 (super_fraction=0) + (100, 100, 50), # original platform_fee was 100 (super_fraction=100) (7965, 2390, 3982), (7965, 7965, 3982), (1_000_000, 333_333, 250_000),