feat(v2): principal-based fee split + per-direction config (closes #38) #42
3 changed files with 132 additions and 3 deletions
feat(v2): m009 + models — split fee fractions by direction (#38 1/5)
Adds the schema delta + Pydantic mirror for per-direction fee configuration: - super_config gains super_cash_in_fee_fraction / super_cash_out_fee_fraction (backfilled from the deprecated singleton on migrate-up so live config preserves intent). - dca_machines gains operator_cash_in_fee_fraction / operator_cash_out_fee_fraction (default 0; operator-settable per machine via the upcoming UI). - dca_settlements gains fee_mismatch_sats BIGINT NULL — Phase-1 observability column per coord-log §2026-06-01T07:00Z (lnbits) + option A locked. - MAX_FEE_FRACTION_PER_DIRECTION = 0.15 lives in calculations.py as the single source of truth (defense-in-depth cap, mirrored on the consumer side per aiolabs/lamassu-next#57). Pydantic validators on the new fields keep [0, 1] range checks; the per-direction cap validation lives on the CRUD path in the next commit (needs cross-row context: super-config change must validate against all machines, machine change against current super-config). Closes one step of #38 (Layer 1 of the operator-configurable fee architecture, parent #37). Subsequent commits add CRUD, principal-based split math (fixes the load-bearing super under-payment bug), and the UI surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
commit
d87d0db324
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
53
models.py
53
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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue