diff --git a/bitspire.py b/bitspire.py index e40c230..59192b2 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,7 +17,10 @@ from __future__ import annotations import json from typing import Any, Optional -from .models import CreateDcaSettlementData, Machine +from loguru import logger + +from .calculations import split_principal_based +from .models import CreateDcaSettlementData, Machine, SuperConfig # Sentinel value bitSpire sets in Payment.extra.source so we know an inbound # payment originated from an ATM cash-out and not some other extension or @@ -219,23 +222,30 @@ def parse_settlement( payment_hash: str, wire_sats: int, extra: dict, - super_fee_fraction: float, + super_config: SuperConfig, ) -> CreateDcaSettlementData: """Build a CreateDcaSettlementData for an inbound payment landing on `machine`'s wallet. + Splits the fee on a principal-based, direction-aware model + (aiolabs/satmachineadmin#37,#38): + + platform_fee_sats = round(principal_sats * super_cash_{type}_fee_fraction) + operator_fee_sats = round(principal_sats * operator_cash_{type}_fee_fraction) + + where the directional super fraction comes from `super_config` and + the operator fraction comes from `machine`. The bitspire-reported + `fee_sats` field is preserved on the settlement as the customer's + actual paid total, but is NOT used as input to the split. + 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`). + `_assert_sat_invariants`) or `tx_type` is unknown. """ - 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 " @@ -253,8 +263,39 @@ def parse_settlement( 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 + tx_type = _coerce_str(extra.get("type")) or "cash_out" + if tx_type == "cash_in": + super_frac = float(super_config.super_cash_in_fee_fraction) + operator_frac = float(machine.operator_cash_in_fee_fraction) + elif tx_type == "cash_out": + super_frac = float(super_config.super_cash_out_fee_fraction) + operator_frac = float(machine.operator_cash_out_fee_fraction) + else: + raise SettlementInvariantError( + f"unknown tx_type={tx_type!r}; expected 'cash_in' or 'cash_out'" + ) + platform_fee_sats, operator_fee_sats = split_principal_based( + principal_sats, super_frac, operator_frac + ) + # Phase-1 observability per aiolabs/satmachineadmin#38 + coord-log + # §2026-06-01T07:00Z (option A locked): compare bitspire's reported + # fee_sats against satmachineadmin's recompute, log on out-of- + # tolerance drift, record the delta unconditionally for triage. + # Phase 2 (settlement-reject) lands after observability data. + fee_mismatch_sats = fee_sats - (platform_fee_sats + operator_fee_sats) + tolerance = max(1, int(principal_sats * 0.001)) + if abs(fee_mismatch_sats) > tolerance: + logger.warning( + f"bitspire fee mismatch on payment {payment_hash[:12]}...: " + f"bitspire_fee_sats={fee_sats} expected={platform_fee_sats + operator_fee_sats} " + f"delta={fee_mismatch_sats} tolerance={tolerance} " + f"principal={principal_sats} super_frac={super_frac:.4f} " + f"operator_frac={operator_frac:.4f} tx_type={tx_type} " + f"machine={machine.machine_npub[:12]}... — " + "Phase 1 observability only, no behavior change. Pre-Layer-3 " + "(lamassu-next#57) the ATM still hardcodes fee fractions, so " + "large deltas here are expected until that ships." + ) 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 @@ -268,7 +309,6 @@ def parse_settlement( # 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 - tx_type = _coerce_str(extra.get("type")) or "cash_out" data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment_hash, @@ -282,6 +322,7 @@ def parse_settlement( fee_sats=fee_sats, platform_fee_sats=platform_fee_sats, operator_fee_sats=operator_fee_sats, + fee_mismatch_sats=fee_mismatch_sats, tx_type=tx_type, bills_json=_json_dumps(extra.get("bills")), cassettes_json=_json_dumps(extra.get("cassettes")), diff --git a/calculations.py b/calculations.py index f2beb96..c76600b 100644 --- a/calculations.py +++ b/calculations.py @@ -16,6 +16,14 @@ What's intentionally NOT here (deleted 2026-05-26): from typing import Dict, Tuple +# Per-direction fee cap (super + operator) for any single direction. +# Locked at 15% per coord-log §2026-06-01T07:22Z (bitspire) — defense in +# depth: producer (this side) refuses to publish/persist > cap; consumer +# (bitspire) refuses to apply > cap. See aiolabs/satmachineadmin#37,#38 +# and aiolabs/lamassu-next#57. +MAX_FEE_FRACTION_PER_DIRECTION = 0.15 + + def calculate_distribution( base_amount_sats: int, client_balances: Dict[str, float], @@ -98,36 +106,49 @@ def calculate_distribution( return distributions -def split_two_stage_commission( - fee_sats: int, super_fee_fraction: float +def split_principal_based( + principal_sats: int, + super_frac: float, + operator_frac: float, ) -> Tuple[int, int]: - """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. + """Compute platform + operator fee shares as independent fractions of + `principal_sats`. Both shares are derived from the customer's + principal (the canonical source of truth), NOT back-derived from + `fee_sats`. - Returns (platform_fee_sats, operator_fee_sats). Platform is rounded; - operator absorbs the rounding remainder so platform_fee + operator_fee - == fee_sats exactly. + Returns (platform_fee_sats, operator_fee_sats). Both are rounded + independently; rounding remainders do NOT compound — the customer + pays whatever bitspire collected, and any drift between (super + + operator) and the bitspire-reported `fee_sats` surfaces via + `dca_settlements.fee_mismatch_sats`. Examples: - >>> split_two_stage_commission(100, 0.30) - (30, 70) - >>> split_two_stage_commission(7965, 0.30) - (2390, 5575) - >>> split_two_stage_commission(100, 0.0) - (0, 100) - >>> split_two_stage_commission(100, 1.0) - (100, 0) + >>> split_principal_based(100_000, 0.03, 0.05) + (3000, 5000) + >>> split_principal_based(266_800, 0.03, 0.0) + (8004, 0) + >>> split_principal_based(100_000, 0.0, 0.0) + (0, 0) + >>> split_principal_based(100_000, 0.15, 0.0) + (15000, 0) + + The pre-#38 bug this corrects: the old math interpreted the super + fee as `fraction_of_fee` rather than `fraction_of_principal`. On a + 100_000-sat principal with an 8% total bitspire fee (= 8_000 sats + fee_sats) and super_fraction=0.03, the bug paid the super + `round(8_000 * 0.03) = 240` sats — ~13× below the intended + `100_000 * 0.03 = 3_000` sats per-settlement. Repeated on every + cash-out since the bitspire wire-shape landed. See + aiolabs/satmachineadmin#37 (parent) + #38 (this layer). """ - 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: + if not (0.0 <= super_frac <= 1.0): + raise ValueError(f"super_frac must be in [0, 1], got {super_frac}") + if not (0.0 <= operator_frac <= 1.0): + raise ValueError(f"operator_frac must be in [0, 1], got {operator_frac}") + if principal_sats <= 0: return 0, 0 - platform = round(fee_sats * super_fee_fraction) - platform = max(0, min(platform, fee_sats)) - operator = fee_sats - platform + platform = max(0, round(principal_sats * super_frac)) + operator = max(0, round(principal_sats * operator_frac)) return platform, operator diff --git a/crud.py b/crud.py index 1144b0c..444a984 100644 --- a/crud.py +++ b/crud.py @@ -80,9 +80,13 @@ 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, created_at, updated_at) + fiat_code, is_active, + operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, + created_at, updated_at) VALUES (:id, :operator_user_id, :machine_npub, :wallet_id, :name, - :location, :fiat_code, :is_active, :created_at, :updated_at) + :location, :fiat_code, :is_active, + :operator_cash_in_fee_fraction, :operator_cash_out_fee_fraction, + :created_at, :updated_at) """, { "id": machine_id, @@ -93,6 +97,8 @@ async def create_machine(operator_user_id: str, data: CreateMachineData) -> Mach "location": data.location, "fiat_code": data.fiat_code, "is_active": True, + "operator_cash_in_fee_fraction": data.operator_cash_in_fee_fraction, + "operator_cash_out_fee_fraction": data.operator_cash_out_fee_fraction, "created_at": now, "updated_at": now, }, @@ -595,13 +601,13 @@ async def create_settlement_idempotent( INSERT INTO satoshimachine.dca_settlements (id, machine_id, payment_hash, bitspire_event_id, bitspire_txid, wire_sats, fiat_amount, fiat_code, exchange_rate, principal_sats, - fee_sats, platform_fee_sats, operator_fee_sats, + fee_sats, platform_fee_sats, operator_fee_sats, fee_mismatch_sats, tx_type, bills_json, cassettes_json, status, error_message, created_at) VALUES (:id, :machine_id, :payment_hash, :bitspire_event_id, :bitspire_txid, :wire_sats, :fiat_amount, :fiat_code, :exchange_rate, :principal_sats, :fee_sats, - :platform_fee_sats, :operator_fee_sats, + :platform_fee_sats, :operator_fee_sats, :fee_mismatch_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -619,6 +625,7 @@ async def create_settlement_idempotent( "fee_sats": data.fee_sats, "platform_fee_sats": data.platform_fee_sats, "operator_fee_sats": data.operator_fee_sats, + "fee_mismatch_sats": data.fee_mismatch_sats, "tx_type": data.tx_type, "bills_json": data.bills_json, "cassettes_json": data.cassettes_json, diff --git a/migrations.py b/migrations.py index e1d957c..97e4a68 100644 --- a/migrations.py +++ b/migrations.py @@ -644,3 +644,94 @@ async def m008_flip_cassette_configs_pk_to_position(db): await db.execute( "ALTER TABLE satoshimachine.cassette_configs_new " "RENAME TO cassette_configs" ) + + +async def m009_split_fee_fractions_by_direction(db): + """Split the singleton `super_fee_fraction` into per-direction fields + and add matching per-machine operator fee fractions. Adds the + `fee_mismatch_sats` audit column on settlements. + + Architectural intent (per aiolabs/satmachineadmin#37): + - Super (lnbits administrator) sets X_in% and X_out% — applies + across every machine on the lnbits instance, calculated against + principal. + - Operator (per-machine) sets Y_in% and Y_out% — sits on top of + super, calculated against principal. + - Total fee charged customer = (X+Y)% of principal per direction. + - Distribution: super gets X% of principal; operator gets Y% + (distributed through commission legs as today). + + Fixes the load-bearing bug where the old `super_fee_fraction` was + interpreted as fraction-of-fee, under-paying the super by ~13× per + cashout. The post-migration split math (bitspire.py:parse_settlement + + calculations.py:split_principal_based) is principal-based. + + Schema delta: + - super_config gains super_cash_in_fee_fraction + + super_cash_out_fee_fraction (both backfilled + from the existing super_fee_fraction so live + config preserves intent across migrate-up). + - dca_machines gains operator_cash_in_fee_fraction + + operator_cash_out_fee_fraction (default 0; + operators set via the new UI surface). + - dca_settlements gains fee_mismatch_sats BIGINT NULL — records + bitspire-reported fee minus expected per + satmachineadmin's principal-based recompute. + Phase 1 observability: log + record, never + reject (per coord-log §2026-06-01T07:00Z + lnbits advisory; option A locked). + + Idempotency via column-probe pattern (same shape as m006's rename + sweep). The deprecated `super_config.super_fee_fraction` singleton + is backfilled into the new directional fields, then dropped in the + same migration — strict-from-the-start per workspace CLAUDE.md + "Backwards-compatibility on pre-public-launch code" (v2-bitspire + hasn't shipped to public users). + """ + additions = [ + ("super_config", "super_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("super_config", "super_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_in_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_machines", "operator_cash_out_fee_fraction", "DECIMAL(10,4) NOT NULL DEFAULT 0.0000"), + ("dca_settlements", "fee_mismatch_sats", "BIGINT"), + ] + for table, col, coltype in additions: + try: + await db.fetchone(f"SELECT {col} FROM satoshimachine.{table} LIMIT 1") + # column already present — migration partially-ran previously, skip + continue + except Exception: + pass + await db.execute( + f"ALTER TABLE satoshimachine.{table} ADD COLUMN {col} {coltype}" + ) + + # Backfill + drop the legacy singleton, gated on the column still + # existing. Once dropped, a re-run of this migration skips both + # steps cleanly. + try: + await db.fetchone( + "SELECT super_fee_fraction FROM satoshimachine.super_config LIMIT 1" + ) + legacy_present = True + except Exception: + legacy_present = False + + if legacy_present: + # Carry the live deployment's super_fee_fraction setting forward + # into both directional fields, but only when the operator hasn't + # already explicitly set per-direction values (i.e., both are + # still at DEFAULT 0). + await db.execute( + """ + UPDATE satoshimachine.super_config + SET super_cash_in_fee_fraction = super_fee_fraction, + super_cash_out_fee_fraction = super_fee_fraction + WHERE super_cash_in_fee_fraction = 0 + AND super_cash_out_fee_fraction = 0 + AND super_fee_fraction > 0 + """ + ) + await db.execute( + "ALTER TABLE satoshimachine.super_config DROP COLUMN super_fee_fraction" + ) diff --git a/models.py b/models.py index 6a18815..1094f6a 100644 --- a/models.py +++ b/models.py @@ -21,6 +21,11 @@ class CreateMachineData(BaseModel): for this machine. The same operator can own multiple machines; each machine gets its own wallet so per-machine accounting via Payment.tag (set to "satmachine:{machine_npub}") works natively. + + `operator_cash_*_fee_fraction` is the per-machine operator fee charged on + top of the platform-wide super fee. Both fractions sit on top of the + super's per-direction fractions and are calculated against principal, + not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ machine_npub: str @@ -28,6 +33,16 @@ class CreateMachineData(BaseModel): name: str | None = None location: str | None = None fiat_code: str = "GTQ" + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return 0.0 + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) class Machine(BaseModel): @@ -39,6 +54,8 @@ class Machine(BaseModel): location: str | None fiat_code: str is_active: bool + operator_cash_in_fee_fraction: float = 0.0 + operator_cash_out_fee_fraction: float = 0.0 created_at: datetime updated_at: datetime @@ -49,6 +66,16 @@ class UpdateMachineData(BaseModel): fiat_code: str | None = None is_active: bool | None = None wallet_id: str | None = None + operator_cash_in_fee_fraction: float | None = None + operator_cash_out_fee_fraction: float | None = None + + @validator("operator_cash_in_fee_fraction", "operator_cash_out_fee_fraction") + def _operator_fee_in_unit_range(cls, v): + if v is None: + return v + if v < 0 or v > 1: + raise ValueError("operator fee fraction must be between 0 and 1") + return round(float(v), 4) # ============================================================================= @@ -220,6 +247,12 @@ class CreateDcaSettlementData(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str # 'cash_out' | 'cash_in' + # Phase-1 observability column (aiolabs/satmachineadmin#38). + # `bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)` — + # positive means bitspire over-reported, negative means under-reported. + # Recorded unconditionally; WARN-logged when |delta| > tolerance. NULL + # only on pre-#38 rows. + fee_mismatch_sats: int | None = None bills_json: str | None = None cassettes_json: str | None = None @@ -239,6 +272,7 @@ class DcaSettlement(BaseModel): platform_fee_sats: int operator_fee_sats: int tx_type: str + fee_mismatch_sats: int | None = None bills_json: str | None cassettes_json: str | None # 'pending' (default at insert) @@ -415,21 +449,26 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str - super_fee_fraction: float + super_cash_in_fee_fraction: float = 0.0 + super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None updated_at: datetime class UpdateSuperConfigData(BaseModel): - super_fee_fraction: float | None = None + super_cash_in_fee_fraction: float | None = None + super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None - @validator("super_fee_fraction") - def fee_in_unit_range(cls, v): + @validator( + "super_cash_in_fee_fraction", + "super_cash_out_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_fraction 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 96b98ef..4da7ee6 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,7 +100,11 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: {super_fee_fraction: 0, super_fee_wallet_id: ''} + data: { + super_cash_in_fee_fraction: 0, + super_cash_out_fee_fraction: 0, + super_fee_wallet_id: '' + } }, // UI configuration ----------------------------------------------- @@ -266,6 +270,17 @@ window.app = Vue.createApp({ }, computed: { + superAnyFee() { + // Banner styling key — true when either directional super fee is + // non-zero, so the banner reads as "active platform fee" instead + // of the muted grey "free instance" state. + const c = this.superConfig + if (!c) return 0 + return ( + Number(c.super_cash_in_fee_fraction || 0) + + Number(c.super_cash_out_fee_fraction || 0) + ) + }, walletOptions() { // g.user is sometimes null on initial mount in LNbits 1.4 — guard it. const wallets = this.g?.user?.wallets || [] @@ -549,7 +564,10 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- openSuperFeeDialog() { this.superFeeDialog.data = { - super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0, + super_cash_in_fee_fraction: + this.superConfig?.super_cash_in_fee_fraction ?? 0, + super_cash_out_fee_fraction: + this.superConfig?.super_cash_out_fee_fraction ?? 0, super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true @@ -562,7 +580,8 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'PUT', SUPER_FEE_PATH, null, { - super_fee_fraction: Number(d.super_fee_fraction), + super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), + super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) @@ -705,7 +724,9 @@ window.app = Vue.createApp({ location: machine.location || '', wallet_id: machine.wallet_id, fiat_code: machine.fiat_code, - is_active: machine.is_active + is_active: machine.is_active, + operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0, + operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0 } this.editMachineDialog.show = true }, @@ -723,7 +744,9 @@ window.app = Vue.createApp({ location: d.location, wallet_id: d.wallet_id, fiat_code: d.fiat_code, - is_active: d.is_active + is_active: d.is_active, + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 } ) const idx = this.machines.findIndex(m => m.id === data.id) @@ -1475,7 +1498,9 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ' + fiat_code: 'GTQ', + operator_cash_in_fee_fraction: 0, + operator_cash_out_fee_fraction: 0 } }, @@ -1485,7 +1510,9 @@ 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() + fiat_code: (d.fiat_code || 'GTQ').trim(), + operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0, + operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0 } }, diff --git a/tasks.py b/tasks.py index 7f2a276..9778a20 100644 --- a/tasks.py +++ b/tasks.py @@ -125,14 +125,14 @@ async def _handle_payment(payment: Payment) -> None: # stamp is missing, SettlementInvariantError on any range/sum # breach. super_config = await get_super_config() - super_fee_fraction = float(super_config.super_fee_fraction) if super_config else 0.0 + assert super_config is not None # m001 inserts the default singleton try: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, wire_sats=payment.sat, extra=extra, - super_fee_fraction=super_fee_fraction, + super_config=super_config, ) except (SettlementMetadataError, SettlementInvariantError) as exc: await _record_rejected(payment, machine, exc) diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 8b3ddf3..ffdb730 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,17 +31,19 @@ + :class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> LNbits platform fee: - ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% - of each transaction's commission. + cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }% + · + cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }% + of each transaction's principal. - Your remainder splits per the rules below. + Operator's per-machine fee rides on top of these.