diff --git a/bitspire.py b/bitspire.py index 59192b2..e40c230 100644 --- a/bitspire.py +++ b/bitspire.py @@ -17,10 +17,7 @@ from __future__ import annotations import json from typing import Any, Optional -from loguru import logger - -from .calculations import split_principal_based -from .models import CreateDcaSettlementData, Machine, SuperConfig +from .models import CreateDcaSettlementData, Machine # 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 @@ -222,30 +219,23 @@ def parse_settlement( payment_hash: str, wire_sats: int, extra: dict, - super_config: SuperConfig, + super_fee_fraction: float, ) -> 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`) or `tx_type` is unknown. + `_assert_sat_invariants`). """ + 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 " @@ -263,39 +253,8 @@ def parse_settlement( f"(lamassu-next#44) requires both. Investigate the ATM " f"firmware on machine {machine.machine_npub[:12]}..." ) - 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." - ) + 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 @@ -309,6 +268,7 @@ 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, @@ -322,7 +282,6 @@ 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 c76600b..f2beb96 100644 --- a/calculations.py +++ b/calculations.py @@ -16,14 +16,6 @@ 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], @@ -106,49 +98,36 @@ def calculate_distribution( return distributions -def split_principal_based( - principal_sats: int, - super_frac: float, - operator_frac: float, +def split_two_stage_commission( + fee_sats: int, super_fee_fraction: float ) -> Tuple[int, int]: - """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`. + """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). 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`. + Returns (platform_fee_sats, operator_fee_sats). Platform is rounded; + operator absorbs the rounding remainder so platform_fee + operator_fee + == fee_sats exactly. Examples: - >>> 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). + >>> 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) """ - 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: + 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 = max(0, round(principal_sats * super_frac)) - operator = max(0, round(principal_sats * operator_frac)) + platform = round(fee_sats * super_fee_fraction) + platform = max(0, min(platform, fee_sats)) + operator = fee_sats - platform return platform, operator diff --git a/crud.py b/crud.py index 444a984..1144b0c 100644 --- a/crud.py +++ b/crud.py @@ -80,13 +80,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, - operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, - 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, - :operator_cash_in_fee_fraction, :operator_cash_out_fee_fraction, - :created_at, :updated_at) + :location, :fiat_code, :is_active, :created_at, :updated_at) """, { "id": machine_id, @@ -97,8 +93,6 @@ 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, }, @@ -601,13 +595,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_mismatch_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, :wire_sats, :fiat_amount, :fiat_code, :exchange_rate, :principal_sats, :fee_sats, - :platform_fee_sats, :operator_fee_sats, :fee_mismatch_sats, + :platform_fee_sats, :operator_fee_sats, :tx_type, :bills_json, :cassettes_json, :status, :error_message, :created_at) """, @@ -625,7 +619,6 @@ 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 97e4a68..e1d957c 100644 --- a/migrations.py +++ b/migrations.py @@ -644,94 +644,3 @@ 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 1094f6a..6a18815 100644 --- a/models.py +++ b/models.py @@ -21,11 +21,6 @@ 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 @@ -33,16 +28,6 @@ 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): @@ -54,8 +39,6 @@ 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 @@ -66,16 +49,6 @@ 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) # ============================================================================= @@ -247,12 +220,6 @@ 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 @@ -272,7 +239,6 @@ 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) @@ -449,26 +415,21 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str - super_cash_in_fee_fraction: float = 0.0 - super_cash_out_fee_fraction: float = 0.0 + super_fee_fraction: float super_fee_wallet_id: str | None updated_at: datetime class UpdateSuperConfigData(BaseModel): - super_cash_in_fee_fraction: float | None = None - super_cash_out_fee_fraction: float | None = None + super_fee_fraction: float | None = None super_fee_wallet_id: str | None = None - @validator( - "super_cash_in_fee_fraction", - "super_cash_out_fee_fraction", - ) - def _fee_in_unit_range(cls, v): + @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 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 4da7ee6..96b98ef 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -100,11 +100,7 @@ window.app = Vue.createApp({ superFeeDialog: { show: false, saving: false, - data: { - super_cash_in_fee_fraction: 0, - super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '' - } + data: {super_fee_fraction: 0, super_fee_wallet_id: ''} }, // UI configuration ----------------------------------------------- @@ -270,17 +266,6 @@ 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 || [] @@ -564,10 +549,7 @@ window.app = Vue.createApp({ // ----------------------------------------------------------------- openSuperFeeDialog() { this.superFeeDialog.data = { - 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_fraction: this.superConfig?.super_fee_fraction ?? 0, super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true @@ -580,8 +562,7 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'PUT', SUPER_FEE_PATH, null, { - 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_fraction: Number(d.super_fee_fraction), super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) @@ -724,9 +705,7 @@ window.app = Vue.createApp({ location: machine.location || '', wallet_id: machine.wallet_id, fiat_code: machine.fiat_code, - 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 + is_active: machine.is_active } this.editMachineDialog.show = true }, @@ -744,9 +723,7 @@ window.app = Vue.createApp({ location: d.location, wallet_id: d.wallet_id, fiat_code: d.fiat_code, - 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 + is_active: d.is_active } ) const idx = this.machines.findIndex(m => m.id === data.id) @@ -1498,9 +1475,7 @@ window.app = Vue.createApp({ wallet_id: null, name: '', location: '', - fiat_code: 'GTQ', - operator_cash_in_fee_fraction: 0, - operator_cash_out_fee_fraction: 0 + fiat_code: 'GTQ' } }, @@ -1510,9 +1485,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(), - 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 + fiat_code: (d.fiat_code || 'GTQ').trim() } }, diff --git a/tasks.py b/tasks.py index 9778a20..7f2a276 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() - assert super_config is not None # m001 inserts the default singleton + 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_config=super_config, + super_fee_fraction=super_fee_fraction, ) except (SettlementMetadataError, SettlementInvariantError) as exc: await _record_rejected(payment, machine, exc) diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index ffdb730..8b3ddf3 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -31,19 +31,17 @@ + :class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'"> LNbits platform fee: - 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. + ${ (superConfig.super_fee_fraction * 100).toFixed(2) }% + of each transaction's commission. - Operator's per-machine fee rides on top of these. + Your remainder splits per the rules below.