diff --git a/calculations.py b/calculations.py index f2beb96..5bfadab 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], diff --git a/migrations.py b/migrations.py index e1d957c..5e0b3ec 100644 --- a/migrations.py +++ b/migrations.py @@ -644,3 +644,77 @@ 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 existing `super_config.super_fee_fraction` column is + NOT dropped here — deprecated, removed in a follow-up release after + callers migrate to the directional fields. + """ + 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 super-config directional fractions from the legacy singleton + # so the live deployment's super_fee_fraction setting carries forward. + # Guarded WHERE clause: only fire when both new fields are still at + # their DEFAULT 0 (i.e., this is a first migrate-up, not a repeat). + 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 + """ + ) diff --git a/models.py b/models.py index 6a18815..1ec2ec4 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,34 @@ class TelemetrySnapshot(BaseModel): class SuperConfig(BaseModel): id: str + # Deprecated singleton fee fraction — retained for one release while + # callers migrate to the per-direction fields below. The new math + # (bitspire.py:parse_settlement) only reads the directional fields. 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): + # Deprecated; setting either directional field is the supported path. + # Writes here continue to apply for one release for migration safety. 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_fee_fraction", + "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)