Merge pull request 'feat(v2): principal-based fee split + per-direction config (closes #38)' (#42) from feat/principal-based-fees into v2-bitspire
Reviewed-on: #42
This commit is contained in:
commit
52911af7b1
15 changed files with 1290 additions and 278 deletions
61
bitspire.py
61
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")),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
15
crud.py
15
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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
49
models.py
49
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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
4
tasks.py
4
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)
|
||||
|
|
|
|||
|
|
@ -31,17 +31,19 @@
|
|||
<q-banner
|
||||
v-if="superConfig"
|
||||
class="q-mb-md"
|
||||
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
||||
:class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
|
||||
<q-icon name="account_balance" :color="superAnyFee > 0 ? 'blue' : 'grey'"></q-icon>
|
||||
</template>
|
||||
<span :style="{fontWeight: 500}">
|
||||
LNbits platform fee:
|
||||
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
|
||||
of each transaction's commission.
|
||||
<span :style="{color: '#1976d2'}">cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }%</span>
|
||||
·
|
||||
<span :style="{color: '#1976d2'}">cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }%</span>
|
||||
of each transaction's principal.
|
||||
</span>
|
||||
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
|
||||
Your remainder splits per the rules below.
|
||||
Operator's per-machine fee rides on top of these.
|
||||
</span>
|
||||
<template v-slot:action>
|
||||
<q-btn v-if="g?.user?.super_user"
|
||||
|
|
@ -792,6 +794,22 @@
|
|||
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
|
||||
<q-input
|
||||
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
|
||||
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
|
||||
<q-input
|
||||
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
|
||||
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
|
|
@ -1226,14 +1244,21 @@
|
|||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
|
||||
Charged on every operator's commission across the LNbits instance.
|
||||
Operators see this as a read-only banner. Wallet ID is where the
|
||||
collected fee lands; typically a wallet you (the super) own.
|
||||
Charged on every transaction's principal across the LNbits
|
||||
instance. Independent per direction. Each direction's total
|
||||
(super + operator) is capped at 15%. Operators see these as a
|
||||
read-only banner. Wallet ID is where the collected fee lands;
|
||||
typically a wallet you (the super) own.
|
||||
</p>
|
||||
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
|
||||
label="Fee % (decimal, 0..1)"
|
||||
hint="0.30 = 30% of every operator's commission"
|
||||
type="number" step="0.0001" min="0" max="1"
|
||||
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
|
||||
label="Cash-in fee % (decimal, 0..0.15)"
|
||||
hint="0.03 = 3% of principal on cash-in transactions"
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
|
||||
label="Cash-out fee % (decimal, 0..0.15)"
|
||||
hint="0.03 = 3% of principal on cash-out transactions"
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
|
||||
label="Super fee destination wallet_id"
|
||||
|
|
@ -1484,6 +1509,16 @@
|
|||
dense outlined></q-select>
|
||||
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
|
||||
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
|
||||
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-toggle v-model="editMachineDialog.data.is_active"
|
||||
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
|
||||
</q-card-section>
|
||||
|
|
|
|||
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
Pytest configuration for the satmachineadmin extension test suite.
|
||||
|
||||
Provides a `loguru_capture` fixture for tests that need to verify
|
||||
loguru WARN/ERROR side-effects. Loguru attaches its default sink to
|
||||
sys.stderr at import time, before pytest's `capsys` wraps stderr, so
|
||||
neither `caplog` (stdlib logging only) nor `capsys` reliably sees
|
||||
loguru output. The fixture adds a list-sink for the test's duration
|
||||
and removes it on teardown.
|
||||
"""
|
||||
|
||||
from typing import Generator, List
|
||||
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loguru_capture() -> Generator[List[str], None, None]:
|
||||
"""Capture loguru log records into a list for the test's duration.
|
||||
|
||||
Usage:
|
||||
def test_warns_on_X(loguru_capture):
|
||||
do_thing_that_warns()
|
||||
assert any("expected message" in msg for msg in loguru_capture)
|
||||
"""
|
||||
captured: List[str] = []
|
||||
handler_id = logger.add(
|
||||
captured.append, level="WARNING", format="{level} {message}"
|
||||
)
|
||||
yield captured
|
||||
logger.remove(handler_id)
|
||||
208
tests/test_fee_cap_validation.py
Normal file
208
tests/test_fee_cap_validation.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
Tests for `views_api._assert_machine_fee_cap_safe` and
|
||||
`_assert_super_config_cap_safe` (aiolabs/satmachineadmin#38, Layer 1).
|
||||
|
||||
Per-direction cap is locked at 15% (super + operator) per coord-log
|
||||
§2026-06-01T07:22Z. Both helpers enforce the same cap from the
|
||||
opposite direction:
|
||||
|
||||
- machine_fee_cap_safe runs at machine create/update; pairs candidate
|
||||
operator fractions against the current super-config
|
||||
- super_config_cap_safe runs at super-config update; pairs candidate
|
||||
super fractions against every active machine's operator fractions
|
||||
and names the first offender so the super-admin can fix the
|
||||
triggering machine
|
||||
|
||||
Tests monkeypatch the CRUD lookups directly — same shape as
|
||||
test_collision_guard.py — so the validators are unit-testable without
|
||||
a live LNbits DB.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from .. import views_api
|
||||
from ..views_api import (
|
||||
_assert_machine_fee_cap_safe,
|
||||
_assert_super_config_cap_safe,
|
||||
)
|
||||
|
||||
|
||||
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0):
|
||||
"""Duck-typed super-config row carrying just the two directional fields
|
||||
the cap helpers read."""
|
||||
return SimpleNamespace(
|
||||
super_cash_in_fee_fraction=in_frac,
|
||||
super_cash_out_fee_fraction=out_frac,
|
||||
)
|
||||
|
||||
|
||||
def _machine(
|
||||
machine_id: str,
|
||||
op_in: float,
|
||||
op_out: float,
|
||||
name: str | None = None,
|
||||
npub: str = "a" * 64,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=machine_id,
|
||||
operator_cash_in_fee_fraction=op_in,
|
||||
operator_cash_out_fee_fraction=op_out,
|
||||
name=name,
|
||||
machine_npub=npub,
|
||||
)
|
||||
|
||||
|
||||
def _patch_super(monkeypatch, value):
|
||||
async def fake_get():
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(views_api, "get_super_config", fake_get)
|
||||
|
||||
|
||||
def _patch_machines(monkeypatch, machines: list):
|
||||
async def fake_list():
|
||||
return machines
|
||||
|
||||
monkeypatch.setattr(views_api, "list_all_active_machines", fake_list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _assert_machine_fee_cap_safe — candidate operator fractions vs current super
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMachineFeeCapSafe:
|
||||
def test_cash_in_cap_exceeded_raises(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.05))
|
||||
# 0.10 + 0.06 = 0.16 > 0.15 → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.06, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
assert "cash-in fee cap exceeded" in exc.value.detail
|
||||
|
||||
def test_cash_out_cap_exceeded_raises(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.05, out_frac=0.10))
|
||||
# 0.10 + 0.06 = 0.16 > 0.15 → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.06))
|
||||
assert exc.value.status_code == 400
|
||||
assert "cash-out fee cap exceeded" in exc.value.detail
|
||||
|
||||
def test_at_exact_cap_passes(self, monkeypatch):
|
||||
"""The cap check is `>`, not `>=` — operators may set exactly
|
||||
15% on either direction without rejection."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.05, 0.05))
|
||||
|
||||
def test_no_super_config_treats_super_as_zero(self, monkeypatch):
|
||||
"""Uninitialised instance (super_config = None) → only operator
|
||||
counts. Cap then degenerates to a pure operator-fee check."""
|
||||
_patch_super(monkeypatch, None)
|
||||
# 0.14 alone is under cap → pass
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.14, 0.14))
|
||||
# 0.16 alone exceeds cap → reject
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.16, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_well_under_cap_passes_silently(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
# Should not raise.
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.0333, 0.0777))
|
||||
|
||||
def test_zero_operator_under_zero_super_passes(self, monkeypatch):
|
||||
"""Free-charge ATM corner case — operator deliberately sets 0
|
||||
on both directions, super is 0 on both. Cap of 0 ≤ 0.15."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.0, out_frac=0.0))
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.0, 0.0))
|
||||
|
||||
def test_error_detail_includes_cap_value(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.0))
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_machine_fee_cap_safe(0.10, 0.0))
|
||||
# 0.10 + 0.10 = 0.20 > 0.15
|
||||
assert "0.15" in exc.value.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _assert_super_config_cap_safe — candidate super fractions vs all machines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuperConfigCapSafe:
|
||||
def test_offending_machine_raises_and_is_named(self, monkeypatch):
|
||||
"""When a super-fee bump pushes one machine over the cap, the
|
||||
rejection names that machine so the super-admin knows which
|
||||
operator's per-machine config blocks the change."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[
|
||||
_machine("m1", op_in=0.01, op_out=0.02, name="Cafe A"),
|
||||
_machine("m2", op_in=0.10, op_out=0.02, name="Greedy ATM"),
|
||||
],
|
||||
)
|
||||
# New super_in = 0.06. m2 has op_in 0.10 → 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, None))
|
||||
assert exc.value.status_code == 400
|
||||
assert "Greedy ATM" in exc.value.detail or "m2" in exc.value.detail
|
||||
|
||||
def test_all_machines_under_cap_passes(self, monkeypatch):
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[
|
||||
_machine("m1", op_in=0.05, op_out=0.05, name="Cafe A"),
|
||||
_machine("m2", op_in=0.03, op_out=0.03, name="Cafe B"),
|
||||
],
|
||||
)
|
||||
# Bump super to 0.08/0.08 → max total = 0.13 + 0.13 = both under cap.
|
||||
asyncio.run(_assert_super_config_cap_safe(0.08, 0.08))
|
||||
|
||||
def test_none_direction_pulls_current_value(self, monkeypatch):
|
||||
"""Caller passes new_super_in=None → check uses current super_in
|
||||
value. Confirms partial-update semantics — caller can change
|
||||
cash-out alone without retransmitting cash-in."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.03))
|
||||
_patch_machines(monkeypatch, [_machine("m1", op_in=0.06, op_out=0.0)])
|
||||
# Skipping in (None) but op_in=0.06 + current super_in=0.10 = 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(None, 0.05))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_no_machines_passes(self, monkeypatch):
|
||||
"""Cap check across an empty fleet is vacuously safe."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.10, out_frac=0.10))
|
||||
_patch_machines(monkeypatch, [])
|
||||
asyncio.run(_assert_super_config_cap_safe(0.12, 0.12))
|
||||
|
||||
def test_no_super_config_with_machines_uses_zero(self, monkeypatch):
|
||||
"""Uninitialised super + new fractions → cap check still runs
|
||||
against the candidate new values + each machine's operator
|
||||
fractions."""
|
||||
_patch_super(monkeypatch, None)
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[_machine("m1", op_in=0.10, op_out=0.0, name="Cafe A")],
|
||||
)
|
||||
# 0.06 + 0.10 = 0.16 > cap.
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, 0.0))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_uses_machine_id_when_name_missing(self, monkeypatch):
|
||||
"""Machines without a `name` set fall back to the id (or npub
|
||||
prefix) for the error message — operator-actionable in either
|
||||
case."""
|
||||
_patch_super(monkeypatch, _super_config(in_frac=0.03, out_frac=0.03))
|
||||
_patch_machines(
|
||||
monkeypatch,
|
||||
[_machine("unnamed-machine-id", op_in=0.10, op_out=0.0, name=None)],
|
||||
)
|
||||
with pytest.raises(Exception) as exc:
|
||||
asyncio.run(_assert_super_config_cap_safe(0.06, None))
|
||||
assert "unnamed-machine-id" in exc.value.detail
|
||||
179
tests/test_fee_mismatch_recording.py
Normal file
179
tests/test_fee_mismatch_recording.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""
|
||||
Tests for `dca_settlements.fee_mismatch_sats` Phase-1 observability
|
||||
(aiolabs/satmachineadmin#38, coord-log §2026-06-01T07:00Z — option A
|
||||
locked: always record, no enforce_fee_match gate).
|
||||
|
||||
Each settlement records:
|
||||
|
||||
fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats)
|
||||
|
||||
Positive = bitspire over-reported (claimed more fee than satmachineadmin
|
||||
recomputed against principal). Negative = bitspire under-reported.
|
||||
Zero = exact match.
|
||||
|
||||
Tolerance for the WARN log is `max(1, int(principal_sats * 0.001))` —
|
||||
1-sat floor, 0.1% relative ceiling. Sub-tolerance drift records the
|
||||
delta silently; over-tolerance drift logs a WARNING. The delta is
|
||||
recorded unconditionally regardless of tolerance — sub-tolerance data
|
||||
is still useful triage data once aggregated.
|
||||
|
||||
Phase 2 (settlement-reject on out-of-tolerance) is a follow-up; this
|
||||
layer is observability-only.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..bitspire import parse_settlement
|
||||
from ..models import Machine, SuperConfig
|
||||
|
||||
|
||||
_NOW = datetime(2026, 6, 1, 12, 0, 0)
|
||||
|
||||
|
||||
def _machine(op_out: float = 0.0) -> Machine:
|
||||
return Machine(
|
||||
id="m1",
|
||||
operator_user_id="op1",
|
||||
machine_npub="a" * 64,
|
||||
wallet_id="w1",
|
||||
name="Test",
|
||||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
operator_cash_in_fee_fraction=0.0,
|
||||
operator_cash_out_fee_fraction=op_out,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _super_config(out_frac: float = 0.0) -> SuperConfig:
|
||||
return SuperConfig(
|
||||
id="default",
|
||||
super_cash_in_fee_fraction=0.0,
|
||||
super_cash_out_fee_fraction=out_frac,
|
||||
super_fee_wallet_id="super-wallet",
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _bitspire_extra(principal_sats: int, fee_sats: int) -> dict:
|
||||
return {
|
||||
"source": "bitspire",
|
||||
"type": "cash_out",
|
||||
"principal_sats": principal_sats,
|
||||
"fee_sats": fee_sats,
|
||||
"fee_fraction": fee_sats / principal_sats if principal_sats else 0.0,
|
||||
"exchange_rate": 0.00001,
|
||||
"fiat_amount": 100.0,
|
||||
"currency": "EUR",
|
||||
"txid": "fake-txid",
|
||||
"nostr_sender_pubkey": "a" * 64,
|
||||
}
|
||||
|
||||
|
||||
def _parse(machine, super_cfg, principal_sats, fee_sats):
|
||||
"""Helper: build extra + invoke parse_settlement with cash-out wire
|
||||
invariant (wire = principal + fee)."""
|
||||
extra = _bitspire_extra(principal_sats, fee_sats)
|
||||
return parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph_test",
|
||||
wire_sats=principal_sats + fee_sats,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
|
||||
|
||||
class TestFeeMismatchSatsRecording:
|
||||
def test_zero_mismatch_when_bitspire_matches_recompute(self):
|
||||
"""super=3%, operator=5%, total=8%. Bitspire reports
|
||||
principal=100_000 fee=8_000 → 100_000 * 0.08 = 8_000 → mismatch=0."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_000)
|
||||
assert data.platform_fee_sats == 3_000
|
||||
assert data.operator_fee_sats == 5_000
|
||||
assert data.fee_mismatch_sats == 0
|
||||
|
||||
def test_positive_mismatch_when_bitspire_over_reports(self):
|
||||
"""super=3%, operator=5% → expected=8_000. Bitspire claims 9_000.
|
||||
Delta = +1_000 (over-reported)."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000)
|
||||
assert data.fee_mismatch_sats == 1_000
|
||||
|
||||
def test_negative_mismatch_when_bitspire_under_reports(self):
|
||||
"""super=3%, operator=5% → expected=8_000. Bitspire claims 7_000.
|
||||
Delta = -1_000 (under-reported)."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_000)
|
||||
assert data.fee_mismatch_sats == -1_000
|
||||
|
||||
def test_pre_layer3_records_large_delta(self):
|
||||
"""Real-world Phase-1 scenario before Layer 3 (lamassu-next#57)
|
||||
ships: ATM hardcodes 7.77% cash-out; operator configures 5%
|
||||
operator + 3% super = 8% total. Bitspire reports
|
||||
100_000 * 0.0777 = 7_770 sats; satmachineadmin recomputes 8_000.
|
||||
Delta is large and visible for triage; behavior unchanged."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=7_770)
|
||||
# Expected = 3_000 + 5_000 = 8_000; bitspire claims 7_770.
|
||||
assert data.fee_mismatch_sats == -230
|
||||
|
||||
|
||||
class TestFeeMismatchWarningLogging:
|
||||
"""Tolerance = max(1, int(principal_sats * 0.001)).
|
||||
For principal=100_000 → tolerance=100. For principal=500 → tolerance=1.
|
||||
|
||||
Uses the `loguru_capture` fixture (defined in conftest.py) to read
|
||||
the WARN log line — pytest's `caplog` only sees stdlib logging,
|
||||
and `capsys` misses loguru's pre-bound stderr sink.
|
||||
"""
|
||||
|
||||
def test_within_tolerance_does_not_warn(self, loguru_capture):
|
||||
"""1-sat delta at principal=100_000 → tolerance=100 → no warn."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_001)
|
||||
assert data.fee_mismatch_sats == 1
|
||||
# Still recorded — the delta is small, the WARN is suppressed.
|
||||
assert not any("fee mismatch" in m.lower() for m in loguru_capture)
|
||||
|
||||
def test_outside_tolerance_logs_warning(self, loguru_capture):
|
||||
"""101-sat delta at principal=100_000 → tolerance=100 → warns."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
# bitspire claims 8_101 (= expected 8_000 + 101 over)
|
||||
data = _parse(machine, super_cfg, principal_sats=100_000, fee_sats=8_101)
|
||||
assert data.fee_mismatch_sats == 101
|
||||
assert any("fee mismatch" in m.lower() for m in loguru_capture)
|
||||
|
||||
def test_warning_includes_diagnostic_fields(self, loguru_capture):
|
||||
"""WARN log line must carry the fields a triage-time operator
|
||||
needs: bitspire's claim, the expected total, the delta, the
|
||||
principal, both fractions, and tx_type."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
_parse(machine, super_cfg, principal_sats=100_000, fee_sats=9_000)
|
||||
log_text = "".join(loguru_capture)
|
||||
assert "bitspire_fee_sats=9000" in log_text
|
||||
assert "expected=8000" in log_text
|
||||
assert "delta=1000" in log_text
|
||||
assert "principal=100000" in log_text
|
||||
assert "tx_type=cash_out" in log_text
|
||||
|
||||
def test_one_sat_floor_warns_on_tiny_principal(self, loguru_capture):
|
||||
"""At principal=500, tolerance=max(1, 0.5)=1. A 2-sat delta
|
||||
triggers the warning — the floor exists so tiny-principal
|
||||
settlements don't go un-policed."""
|
||||
machine = _machine(op_out=0.05)
|
||||
super_cfg = _super_config(out_frac=0.03)
|
||||
# principal=500 → expected fee = 500 * 0.08 = 40 sats.
|
||||
# Bitspire claims 42 → delta=2. Tolerance=max(1, 0)=1. Warns.
|
||||
data = _parse(machine, super_cfg, principal_sats=500, fee_sats=42)
|
||||
assert data.fee_mismatch_sats == 2
|
||||
assert any("fee mismatch" in m.lower() for m in loguru_capture)
|
||||
150
tests/test_operator_split_legs.py
Normal file
150
tests/test_operator_split_legs.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""
|
||||
Tests for `allocate_operator_split_legs` (operator's commission-leg
|
||||
distribution) and the partial-dispense ratio math in
|
||||
`apply_partial_dispense_and_redistribute`.
|
||||
|
||||
Both are split-arithmetic concerns that survive the post-#38
|
||||
principal-based-math refactor:
|
||||
|
||||
- `allocate_operator_split_legs` slices the operator's share across
|
||||
their commission legs by their per-leg fractions. Function-level,
|
||||
no fee-model coupling.
|
||||
- Partial-dispense ratio math (in distribution.py) preserves the
|
||||
ORIGINAL platform/operator ratio recorded against a settlement at
|
||||
land time when an operator partial-dispenses post-hoc. The ratio
|
||||
comes from the absolute platform_fee_sats / fee_sats recorded on
|
||||
the settlement row, NOT the current super-config fractions — the
|
||||
contract is locked at landing.
|
||||
|
||||
Pre-#38 tests for `split_two_stage_commission` lived here; that
|
||||
function was removed when the principal-based math landed
|
||||
(aiolabs/satmachineadmin#38).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ..calculations import allocate_operator_split_legs
|
||||
|
||||
|
||||
class TestAllocateOperatorSplitLegs:
|
||||
"""Operator's remaining share split into commission legs by fraction."""
|
||||
|
||||
def test_plan_example_50_30_20_on_70(self):
|
||||
amounts = allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
||||
assert amounts == [35, 21, 14]
|
||||
|
||||
def test_realistic_50_30_20_on_5575(self):
|
||||
amounts = allocate_operator_split_legs(5575, [0.5, 0.3, 0.2])
|
||||
# Plan-scale: 5575 * (0.5, 0.3, 0.2) = (2787.5, 1672.5, 1115)
|
||||
# Last leg absorbs rounding remainders so sum == 5575 exactly.
|
||||
assert sum(amounts) == 5575
|
||||
assert amounts[0] == round(5575 * 0.5)
|
||||
assert amounts[1] == round(5575 * 0.3)
|
||||
# Last leg absorbs the remainder.
|
||||
assert amounts[2] == 5575 - amounts[0] - amounts[1]
|
||||
|
||||
def test_single_leg_full_remainder(self):
|
||||
amounts = allocate_operator_split_legs(7965, [1.0])
|
||||
assert amounts == [7965]
|
||||
|
||||
def test_zero_operator_fee_zeros_all_legs(self):
|
||||
amounts = allocate_operator_split_legs(0, [0.5, 0.3, 0.2])
|
||||
assert amounts == [0, 0, 0]
|
||||
|
||||
def test_empty_legs_list_returns_empty(self):
|
||||
amounts = allocate_operator_split_legs(100, [])
|
||||
assert amounts == []
|
||||
|
||||
def test_last_leg_absorbs_rounding_remainder(self):
|
||||
# 100 sats split [1/3, 1/3, 1/3] — last leg absorbs the +1 remainder.
|
||||
amounts = allocate_operator_split_legs(100, [1 / 3, 1 / 3, 1 / 3])
|
||||
assert sum(amounts) == 100
|
||||
assert amounts[0] == round(100 / 3) # 33
|
||||
assert amounts[1] == round(100 / 3) # 33
|
||||
# Last leg absorbs the rounding (34, not 33) so total == 100.
|
||||
assert amounts[2] == 100 - amounts[0] - amounts[1]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator_fee,fractions",
|
||||
[
|
||||
(1, [0.5, 0.5]),
|
||||
(7, [0.5, 0.3, 0.2]),
|
||||
(100, [0.5, 0.5]),
|
||||
(5575, [0.5, 0.3, 0.2]),
|
||||
(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, fractions):
|
||||
amounts = allocate_operator_split_legs(operator_fee, fractions)
|
||||
assert sum(amounts) == operator_fee
|
||||
assert all(a >= 0 for a in amounts)
|
||||
|
||||
|
||||
class TestPartialDispenseSplitRatio:
|
||||
"""Partial-dispense recompute (closes #11 H6) must preserve the
|
||||
ORIGINAL platform/operator ratio recorded on the settlement row at
|
||||
land time. Super raising or lowering a global rate post-hoc must
|
||||
NOT retroactively change an existing settlement's share split.
|
||||
|
||||
The math is inlined in `apply_partial_dispense_and_redistribute`
|
||||
(distribution.py) rather than in a standalone function. These tests
|
||||
mirror the inline math so a future refactor doesn't silently change
|
||||
the invariant.
|
||||
"""
|
||||
|
||||
def _recompute(self, original_fee, original_platform_fee, new_fee):
|
||||
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
|
||||
if original_fee > 0:
|
||||
ratio = original_platform_fee / original_fee
|
||||
else:
|
||||
ratio = 0.0
|
||||
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_30pct_lands_then_partial(self):
|
||||
# Landed at platform ratio 30/100 = 0.30; new fee = 50.
|
||||
# Original ratio preserved → new_platform = round(50 * 0.30) = 15.
|
||||
new_platform, new_operator = self._recompute(100, 30, 50)
|
||||
assert new_platform == 15
|
||||
assert new_operator == 35
|
||||
assert new_platform + new_operator == 50
|
||||
|
||||
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
|
||||
# Landed with platform=2390, fee=7965 (ratio ≈ 0.30). Super then
|
||||
# bumps the global rate to 50%. Operator partial-dispenses to
|
||||
# 50% gross → new_fee = round(7965 * 0.5) = 3982. The 30% ratio
|
||||
# at land time MUST persist regardless of the new super rate.
|
||||
new_platform, new_operator = self._recompute(7965, 2390, 3982)
|
||||
# Expected with original ratio: round(3982 * 0.30006...) = 1195
|
||||
# With (broken) current rate of 50%: would be 1991 — much higher.
|
||||
assert 1190 <= new_platform <= 1200
|
||||
assert new_platform + new_operator == 3982
|
||||
# Original platform share was ~30%; preserved within rounding.
|
||||
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
|
||||
|
||||
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_fee(self):
|
||||
# Random-ish parameter sweep over realistic values.
|
||||
cases = [
|
||||
(100, 30, 50),
|
||||
(100, 0, 50), # original platform_fee was 0
|
||||
(100, 100, 50), # original platform_fee was full fee
|
||||
(7965, 2390, 3982),
|
||||
(7965, 7965, 3982),
|
||||
(1_000_000, 333_333, 250_000),
|
||||
]
|
||||
for orig_comm, orig_plat, new_comm in cases:
|
||||
new_platform, new_operator = self._recompute(
|
||||
orig_comm, orig_plat, new_comm
|
||||
)
|
||||
assert new_platform + new_operator == new_comm, (
|
||||
f"sum invariant violated: {orig_comm=} {orig_plat=} "
|
||||
f"{new_comm=} → {new_platform=} {new_operator=}"
|
||||
)
|
||||
assert 0 <= new_platform <= new_comm
|
||||
270
tests/test_principal_based_fees.py
Normal file
270
tests/test_principal_based_fees.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""
|
||||
Tests for the post-#38 principal-based fee split:
|
||||
|
||||
- `calculations.split_principal_based(principal_sats, super_frac,
|
||||
operator_frac)` — pure-function math
|
||||
- `bitspire.parse_settlement` — directional dispatch by tx_type
|
||||
("cash_in" → super_cash_in + operator_cash_in;
|
||||
"cash_out" → super_cash_out + operator_cash_out)
|
||||
|
||||
The bug this layer closes: pre-#38 math interpreted super_fee_fraction
|
||||
as fraction-of-fee instead of fraction-of-principal, under-paying the
|
||||
super by ~13× per cashout. Tests below pin the new math to the
|
||||
intended fraction-of-principal model and verify the per-direction
|
||||
routing through parse_settlement.
|
||||
|
||||
Fee mismatch recording (`fee_mismatch_sats` column, Phase 1
|
||||
observability per coord-log §2026-06-01T07:00Z) lands in the next
|
||||
commit; those tests live in `test_fee_mismatch_recording.py`.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from ..bitspire import SettlementInvariantError, parse_settlement
|
||||
from ..calculations import split_principal_based
|
||||
from ..models import Machine, SuperConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split_principal_based — pure-function math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitPrincipalBased:
|
||||
def test_super_fraction_only(self):
|
||||
"""Operator at 0% — super takes exactly super_frac of principal,
|
||||
operator gets 0."""
|
||||
platform, operator = split_principal_based(100_000, 0.03, 0.0)
|
||||
assert platform == 3_000
|
||||
assert operator == 0
|
||||
|
||||
def test_operator_fraction_only(self):
|
||||
"""Super at 0% — operator takes exactly operator_frac of
|
||||
principal, platform gets 0."""
|
||||
platform, operator = split_principal_based(100_000, 0.0, 0.05)
|
||||
assert platform == 0
|
||||
assert operator == 5_000
|
||||
|
||||
def test_both_fractions(self):
|
||||
"""Both shares independently computed against principal — total
|
||||
is super + operator, not anchored to any fee_sats input."""
|
||||
platform, operator = split_principal_based(100_000, 0.03, 0.05)
|
||||
assert platform == 3_000
|
||||
assert operator == 5_000
|
||||
|
||||
def test_zero_principal_yields_zero_shares(self):
|
||||
platform, operator = split_principal_based(0, 0.03, 0.05)
|
||||
assert platform == 0
|
||||
assert operator == 0
|
||||
|
||||
def test_negative_principal_yields_zero_shares(self):
|
||||
"""Defensive: negative principal can't happen in production but
|
||||
the function should not produce negative outputs if it ever does."""
|
||||
platform, operator = split_principal_based(-100, 0.03, 0.05)
|
||||
assert platform == 0
|
||||
assert operator == 0
|
||||
|
||||
def test_rounding_does_not_compound(self):
|
||||
"""The two shares round independently — there is no carryover.
|
||||
On a 1_000_000-sat principal with super=0.0333, operator=0.0777,
|
||||
each share rounds against principal individually."""
|
||||
platform, operator = split_principal_based(1_000_000, 0.0333, 0.0777)
|
||||
assert platform == round(1_000_000 * 0.0333) # 33_300
|
||||
assert operator == round(1_000_000 * 0.0777) # 77_700
|
||||
|
||||
def test_super_frac_out_of_range_raises(self):
|
||||
with pytest.raises(ValueError, match="super_frac"):
|
||||
split_principal_based(100_000, 1.5, 0.0)
|
||||
with pytest.raises(ValueError, match="super_frac"):
|
||||
split_principal_based(100_000, -0.1, 0.0)
|
||||
|
||||
def test_operator_frac_out_of_range_raises(self):
|
||||
with pytest.raises(ValueError, match="operator_frac"):
|
||||
split_principal_based(100_000, 0.0, 1.5)
|
||||
with pytest.raises(ValueError, match="operator_frac"):
|
||||
split_principal_based(100_000, 0.0, -0.1)
|
||||
|
||||
def test_super_under_payment_bug_regression(self):
|
||||
"""Direct regression test for the bug this layer closes.
|
||||
|
||||
Pre-#38 math (deleted): `round(fee_sats * super_fraction)` with
|
||||
fee_sats=8_000 (= 8% of 100_000 principal) and super_fraction=0.03
|
||||
produced platform_fee_sats=240 — ~13× below intent.
|
||||
|
||||
Post-#38 math: split_principal_based(100_000, 0.03, 0.05) gives
|
||||
platform=3_000, which IS the intended 3% of principal."""
|
||||
platform, operator = split_principal_based(100_000, 0.03, 0.05)
|
||||
# Post-#38: super gets intended 3% of principal (3_000 sats)
|
||||
# Pre-#38 would have produced ~240 sats from round(8000 * 0.03).
|
||||
assert platform == 3_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_settlement — directional dispatch via tx_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bitspire_extra(
|
||||
*,
|
||||
tx_type: str = "cash_out",
|
||||
principal_sats: int = 100_000,
|
||||
fee_sats: int = 8_000,
|
||||
exchange_rate: float = 0.00001,
|
||||
fiat_amount: float = 100.0,
|
||||
currency: str = "EUR",
|
||||
nostr_sender_pubkey: str = "a" * 64,
|
||||
extra_overrides: dict | None = None,
|
||||
):
|
||||
"""Canonical bitspire-stamped Payment.extra dict for tests. Mirrors
|
||||
the shape required by `is_bitspire_payment` + the canonical sat-
|
||||
amount invariants in `_assert_sat_invariants`."""
|
||||
base = {
|
||||
"source": "bitspire",
|
||||
"type": tx_type,
|
||||
"principal_sats": principal_sats,
|
||||
"fee_sats": fee_sats,
|
||||
"fee_fraction": fee_sats / principal_sats if principal_sats else 0.0,
|
||||
"exchange_rate": exchange_rate,
|
||||
"fiat_amount": fiat_amount,
|
||||
"currency": currency,
|
||||
"txid": "fake-txid",
|
||||
"nostr_sender_pubkey": nostr_sender_pubkey,
|
||||
}
|
||||
if extra_overrides:
|
||||
base.update(extra_overrides)
|
||||
return base
|
||||
|
||||
|
||||
_NOW = datetime(2026, 6, 1, 12, 0, 0)
|
||||
|
||||
|
||||
def _machine(
|
||||
machine_id: str = "m1",
|
||||
machine_npub: str = "a" * 64,
|
||||
op_in: float = 0.0,
|
||||
op_out: float = 0.0,
|
||||
fiat_code: str = "EUR",
|
||||
) -> Machine:
|
||||
return Machine(
|
||||
id=machine_id,
|
||||
operator_user_id="op1",
|
||||
machine_npub=machine_npub,
|
||||
wallet_id="w1",
|
||||
name="Test",
|
||||
location=None,
|
||||
fiat_code=fiat_code,
|
||||
is_active=True,
|
||||
operator_cash_in_fee_fraction=op_in,
|
||||
operator_cash_out_fee_fraction=op_out,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _super_config(in_frac: float = 0.0, out_frac: float = 0.0) -> SuperConfig:
|
||||
return SuperConfig(
|
||||
id="default",
|
||||
super_cash_in_fee_fraction=in_frac,
|
||||
super_cash_out_fee_fraction=out_frac,
|
||||
super_fee_wallet_id="super-wallet",
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
class TestParseSettlementDirectional:
|
||||
def test_cash_out_uses_cash_out_fractions(self):
|
||||
"""tx_type='cash_out' must route to super_cash_out +
|
||||
operator_cash_out fractions."""
|
||||
machine = _machine(op_in=0.10, op_out=0.05)
|
||||
super_cfg = _super_config(in_frac=0.10, out_frac=0.03)
|
||||
extra = _bitspire_extra(tx_type="cash_out", principal_sats=100_000)
|
||||
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph1",
|
||||
wire_sats=108_000,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
# super_cash_out=0.03, operator_cash_out=0.05 against 100_000 principal
|
||||
assert data.platform_fee_sats == 3_000
|
||||
assert data.operator_fee_sats == 5_000
|
||||
assert data.tx_type == "cash_out"
|
||||
|
||||
def test_cash_in_uses_cash_in_fractions(self):
|
||||
"""tx_type='cash_in' must route to super_cash_in +
|
||||
operator_cash_in fractions (not cash_out)."""
|
||||
machine = _machine(op_in=0.04, op_out=0.10)
|
||||
super_cfg = _super_config(in_frac=0.02, out_frac=0.10)
|
||||
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
|
||||
|
||||
# cash-in wire invariant: wire = principal - fee
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph2",
|
||||
wire_sats=92_000,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
# super_cash_in=0.02, operator_cash_in=0.04 against 100_000 principal
|
||||
assert data.platform_fee_sats == 2_000
|
||||
assert data.operator_fee_sats == 4_000
|
||||
assert data.tx_type == "cash_in"
|
||||
|
||||
def test_unknown_tx_type_raises(self):
|
||||
machine = _machine()
|
||||
super_cfg = _super_config()
|
||||
extra = _bitspire_extra(
|
||||
tx_type="cash_out",
|
||||
extra_overrides={"type": "withdrawal"}, # not a known direction
|
||||
)
|
||||
with pytest.raises(SettlementInvariantError, match="unknown tx_type"):
|
||||
parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph3",
|
||||
wire_sats=108_000,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
|
||||
def test_zero_fractions_zero_split(self):
|
||||
"""Free-charge ATM: both super + operator at 0 → platform and
|
||||
operator fees are both 0, principal is the full take."""
|
||||
machine = _machine(op_in=0.0, op_out=0.0)
|
||||
super_cfg = _super_config(in_frac=0.0, out_frac=0.0)
|
||||
extra = _bitspire_extra(
|
||||
tx_type="cash_out", principal_sats=100_000, fee_sats=0
|
||||
)
|
||||
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph4",
|
||||
wire_sats=100_000,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
assert data.platform_fee_sats == 0
|
||||
assert data.operator_fee_sats == 0
|
||||
assert data.principal_sats == 100_000
|
||||
|
||||
def test_cash_in_does_not_use_cash_out_config(self):
|
||||
"""Cross-direction guard: cash-in must NOT pick up cash-out's
|
||||
super or operator fractions even when they're set differently.
|
||||
Pin both directions concretely to prove the dispatch."""
|
||||
machine = _machine(op_in=0.01, op_out=0.10)
|
||||
super_cfg = _super_config(in_frac=0.01, out_frac=0.10)
|
||||
extra = _bitspire_extra(tx_type="cash_in", principal_sats=100_000)
|
||||
|
||||
# cash-in wire invariant: wire = principal - fee
|
||||
data = parse_settlement(
|
||||
machine=machine,
|
||||
payment_hash="ph5",
|
||||
wire_sats=92_000,
|
||||
extra=extra,
|
||||
super_config=super_cfg,
|
||||
)
|
||||
# Cash-in totals = 0.01 + 0.01 = 0.02; not 0.10 + 0.10 = 0.20
|
||||
assert data.platform_fee_sats == 1_000 # 100_000 * 0.01
|
||||
assert data.operator_fee_sats == 1_000 # 100_000 * 0.01
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
"""
|
||||
Tests for the v2 two-stage commission split (super first, operator remainder).
|
||||
|
||||
The plan calls out a verification scenario explicitly:
|
||||
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_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
|
||||
|
||||
from ..calculations import (
|
||||
allocate_operator_split_legs,
|
||||
split_two_stage_commission,
|
||||
)
|
||||
|
||||
|
||||
class TestSplitTwoStageCommission:
|
||||
"""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)
|
||||
assert platform == 30
|
||||
assert operator == 70
|
||||
assert platform + operator == 100
|
||||
|
||||
def test_realistic_7965sats_30pct(self):
|
||||
# From the plan's 2000 GTQ → 266800 sats @ 3% commission example.
|
||||
platform, operator = split_two_stage_commission(7965, 0.30)
|
||||
assert platform == 2390 # round(7965 * 0.30) = 2389.5 → 2390
|
||||
assert operator == 5575 # 7965 - 2390
|
||||
assert platform + operator == 7965
|
||||
|
||||
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_fraction_one_takes_everything(self):
|
||||
platform, operator = split_two_stage_commission(7965, 1.0)
|
||||
assert platform == 7965
|
||||
assert operator == 0
|
||||
|
||||
def test_zero_commission(self):
|
||||
platform, operator = split_two_stage_commission(0, 0.30)
|
||||
assert platform == 0
|
||||
assert operator == 0
|
||||
|
||||
def test_negative_commission_clamps_to_zero(self):
|
||||
# Defensive: should never happen, but verify we don't go negative.
|
||||
platform, operator = split_two_stage_commission(-100, 0.30)
|
||||
assert platform == 0
|
||||
assert operator == 0
|
||||
|
||||
@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:
|
||||
"""Stage-2: operator's remainder split across N leg wallets per pct rules."""
|
||||
|
||||
def test_plan_example_50_30_20_on_70(self):
|
||||
amounts = allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
||||
assert amounts == [35, 21, 14]
|
||||
assert sum(amounts) == 70
|
||||
|
||||
def test_realistic_50_30_20_on_5575(self):
|
||||
amounts = allocate_operator_split_legs(5575, [0.5, 0.3, 0.2])
|
||||
# 50%: round(2787.5) = 2788; 30%: round(1672.5) = 1672; last absorbs
|
||||
# remainder: 5575 - 2788 - 1672 = 1115.
|
||||
# Note: round() uses banker's rounding so 2787.5 → 2788 actually
|
||||
# because 2788 is even. Confirm by total invariant.
|
||||
assert sum(amounts) == 5575
|
||||
assert len(amounts) == 3
|
||||
|
||||
def test_single_leg_full_remainder(self):
|
||||
amounts = allocate_operator_split_legs(100, [1.0])
|
||||
assert amounts == [100]
|
||||
|
||||
def test_zero_operator_fee_zeros_all_legs(self):
|
||||
amounts = allocate_operator_split_legs(0, [0.5, 0.5])
|
||||
assert amounts == [0, 0]
|
||||
|
||||
def test_empty_legs_list_returns_empty(self):
|
||||
amounts = allocate_operator_split_legs(100, [])
|
||||
assert amounts == []
|
||||
|
||||
def test_last_leg_absorbs_rounding_remainder(self):
|
||||
# 100 / 3 ≈ 33.33 each; rounding makes the first two 33 and last 34.
|
||||
amounts = allocate_operator_split_legs(100, [1 / 3, 1 / 3, 1 / 3])
|
||||
assert sum(amounts) == 100
|
||||
assert amounts[0] == round(100 / 3) # 33
|
||||
assert amounts[1] == round(100 / 3) # 33
|
||||
# Last leg absorbs the rounding (34, not 33) so total == 100.
|
||||
assert amounts[2] == 100 - amounts[0] - amounts[1]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator_fee,fractions",
|
||||
[
|
||||
(1, [0.5, 0.5]),
|
||||
(7, [0.5, 0.3, 0.2]),
|
||||
(100, [0.5, 0.5]),
|
||||
(5575, [0.5, 0.3, 0.2]),
|
||||
(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, fractions):
|
||||
amounts = allocate_operator_split_legs(operator_fee, fractions)
|
||||
assert sum(amounts) == operator_fee
|
||||
assert all(a >= 0 for a in amounts)
|
||||
|
||||
|
||||
class TestEndToEndScenarios:
|
||||
"""The full two-stage split — super then operator legs — composed."""
|
||||
|
||||
def test_plan_example_full(self):
|
||||
# 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_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_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
|
||||
# Operator has zero to distribute; both legs get zero.
|
||||
assert legs == [0, 0]
|
||||
assert platform + sum(legs) == 7965
|
||||
|
||||
|
||||
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_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_fee, original_platform_fee, new_fee):
|
||||
"""Mirror of the ratio math in apply_partial_dispense_and_redistribute."""
|
||||
if original_fee > 0:
|
||||
ratio = original_platform_fee / original_fee
|
||||
else:
|
||||
ratio = 0.0
|
||||
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_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
|
||||
assert new_operator == 35
|
||||
assert new_platform + new_operator == 50
|
||||
|
||||
def test_super_changed_rate_doesnt_affect_existing_settlement(self):
|
||||
# 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_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
|
||||
# With (broken) current rate of 50%: would be 1991 — much higher.
|
||||
assert 1190 <= new_platform <= 1200
|
||||
assert new_platform + new_operator == 3982
|
||||
# Original platform share was ~30%; preserved within rounding.
|
||||
assert abs(new_platform / 3982 - 2390 / 7965) < 0.001
|
||||
|
||||
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_fee(self):
|
||||
# Random-ish parameter sweep over realistic values.
|
||||
cases = [
|
||||
(100, 30, 50),
|
||||
(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),
|
||||
]
|
||||
for orig_comm, orig_plat, new_comm in cases:
|
||||
new_platform, new_operator = self._recompute(
|
||||
orig_comm, orig_plat, new_comm
|
||||
)
|
||||
assert new_platform + new_operator == new_comm, (
|
||||
f"sum invariant violated: {orig_comm=} {orig_plat=} "
|
||||
f"{new_comm=} → {new_platform=} {new_operator=}"
|
||||
)
|
||||
assert 0 <= new_platform <= new_comm
|
||||
126
views_api.py
126
views_api.py
|
|
@ -14,6 +14,7 @@ from lnbits.core.models import User
|
|||
from lnbits.decorators import check_super_user, check_user_exists
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
|
||||
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
|
||||
from .cassette_transport import (
|
||||
CassetteTransportError,
|
||||
OperatorIdentityMissing,
|
||||
|
|
@ -48,6 +49,7 @@ from .crud import (
|
|||
get_settlements_for_operator,
|
||||
get_stuck_settlements_for_operator,
|
||||
get_super_config,
|
||||
list_all_active_machines,
|
||||
list_cassette_configs_for_machine,
|
||||
lp_is_onboarded,
|
||||
replace_commission_splits,
|
||||
|
|
@ -147,6 +149,105 @@ async def _assert_no_pubkey_collision(machine_npub: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def _assert_machine_fee_cap_safe(
|
||||
operator_in: float,
|
||||
operator_out: float,
|
||||
) -> None:
|
||||
"""Reject create/update if (super_X + operator_X) > 0.15 for either
|
||||
direction. Locked at 15% per coord-log §2026-06-01T07:22Z; defense in
|
||||
depth — the bitspire consumer enforces the same cap on the wire-format
|
||||
side (aiolabs/lamassu-next#57).
|
||||
|
||||
Fetches the current super-config singleton to pair against the
|
||||
candidate per-machine fractions. NULL super-config (uninitialised
|
||||
instance) treats super contribution as 0 — the cap then degenerates
|
||||
to a pure operator-fee check.
|
||||
"""
|
||||
super_config = await get_super_config()
|
||||
super_in = (
|
||||
float(super_config.super_cash_in_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
super_out = (
|
||||
float(super_config.super_cash_out_fee_fraction) if super_config else 0.0
|
||||
)
|
||||
# Fields are stored as DECIMAL(10,4) and Pydantic validators round to
|
||||
# 4 decimals on the way in, so the source-of-truth precision is 1e-4.
|
||||
# Round the float-arithmetic sum to that precision before comparison so
|
||||
# `0.10 + 0.05 = 0.15000000000000002` (IEEE 754) doesn't trip the cap.
|
||||
total_in = round(super_in + operator_in, 4)
|
||||
total_out = round(super_out + operator_out, 4)
|
||||
if total_in > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"cash-in fee cap exceeded: super {super_in:.4f} + operator "
|
||||
f"{operator_in:.4f} = {total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
if total_out > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"cash-out fee cap exceeded: super {super_out:.4f} + operator "
|
||||
f"{operator_out:.4f} = {total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _assert_super_config_cap_safe(
|
||||
new_super_in: float | None,
|
||||
new_super_out: float | None,
|
||||
) -> None:
|
||||
"""Reject super-config update if any active machine's
|
||||
(new_super + operator) > 0.15 for either direction. Same cap policy
|
||||
as _assert_machine_fee_cap_safe but checked across the fleet because
|
||||
a super update affects every machine.
|
||||
|
||||
`None` for a direction means "no change" — pulls the current value
|
||||
from super-config so the cap check still runs against the resulting
|
||||
post-update state.
|
||||
"""
|
||||
current = await get_super_config()
|
||||
effective_in = (
|
||||
float(new_super_in)
|
||||
if new_super_in is not None
|
||||
else (float(current.super_cash_in_fee_fraction) if current else 0.0)
|
||||
)
|
||||
effective_out = (
|
||||
float(new_super_out)
|
||||
if new_super_out is not None
|
||||
else (float(current.super_cash_out_fee_fraction) if current else 0.0)
|
||||
)
|
||||
machines = await list_all_active_machines()
|
||||
for m in machines:
|
||||
op_in = float(m.operator_cash_in_fee_fraction)
|
||||
op_out = float(m.operator_cash_out_fee_fraction)
|
||||
# Round to DECIMAL(10,4) precision — see _assert_machine_fee_cap_safe
|
||||
# for the IEEE 754 motivation.
|
||||
total_in = round(effective_in + op_in, 4)
|
||||
total_out = round(effective_out + op_out, 4)
|
||||
if total_in > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"super cash-in fee {effective_in:.4f} would exceed cap "
|
||||
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||
f"+ operator {op_in:.4f} = "
|
||||
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
if total_out > MAX_FEE_FRACTION_PER_DIRECTION:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"super cash-out fee {effective_out:.4f} would exceed cap "
|
||||
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||
f"+ operator {op_out:.4f} = "
|
||||
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Machines
|
||||
# =============================================================================
|
||||
|
|
@ -158,6 +259,10 @@ async def api_create_machine(
|
|||
) -> Machine:
|
||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
||||
await _assert_no_pubkey_collision(data.machine_npub)
|
||||
await _assert_machine_fee_cap_safe(
|
||||
data.operator_cash_in_fee_fraction,
|
||||
data.operator_cash_out_fee_fraction,
|
||||
)
|
||||
machine = await create_machine(user.id, data)
|
||||
return machine
|
||||
|
||||
|
|
@ -194,6 +299,23 @@ async def api_update_machine(
|
|||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||
if data.wallet_id is not None:
|
||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
||||
# Cap check against post-update state — partial PATCH semantics:
|
||||
# unset directional fields keep the machine's current value.
|
||||
if (
|
||||
data.operator_cash_in_fee_fraction is not None
|
||||
or data.operator_cash_out_fee_fraction is not None
|
||||
):
|
||||
candidate_in = (
|
||||
data.operator_cash_in_fee_fraction
|
||||
if data.operator_cash_in_fee_fraction is not None
|
||||
else float(machine.operator_cash_in_fee_fraction)
|
||||
)
|
||||
candidate_out = (
|
||||
data.operator_cash_out_fee_fraction
|
||||
if data.operator_cash_out_fee_fraction is not None
|
||||
else float(machine.operator_cash_out_fee_fraction)
|
||||
)
|
||||
await _assert_machine_fee_cap_safe(candidate_in, candidate_out)
|
||||
updated = await update_machine(machine_id, data)
|
||||
if updated is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
|
||||
|
|
@ -808,6 +930,10 @@ async def api_update_super_config(
|
|||
commission, plus the destination wallet for collecting it. The fee is
|
||||
enforced before the operator's own commission_splits ruleset fires
|
||||
(see distribution.process_settlement)."""
|
||||
await _assert_super_config_cap_safe(
|
||||
data.super_cash_in_fee_fraction,
|
||||
data.super_cash_out_fee_fraction,
|
||||
)
|
||||
config = await update_super_config(data)
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue