feat(v2): principal-based fee split — fixes super under-payment (#38 3/5)

Replaces the broken fraction-of-fee math with fraction-of-principal,
direction-aware. Pre-#38: super_fee_fraction was interpreted as
`round(fee_sats * super_fraction)`, paying super ~13× below intent on
every cashout since the bitspire wire-shape landed. Post-#38: super
and operator shares are computed independently against principal
using the per-direction fractions from SuperConfig + Machine.

Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch
code" (v2-bitspire hasn't shipped to users), no compat shims:

- calculations.py: delete `split_two_stage_commission` (legacy
  fraction-of-fee). Keep `split_principal_based` as the sole split fn.
- migrations.py m009: extend to also DROP the deprecated
  `super_fee_fraction` column after backfilling its value into the
  new directional fields.
- models.py: drop `super_fee_fraction` from SuperConfig +
  UpdateSuperConfigData entirely.
- bitspire.py parse_settlement: new signature takes `super_config:
  SuperConfig` instead of `super_fee_fraction: float`. Resolves
  directional fractions from super_config + machine by tx_type, then
  computes via split_principal_based. Raises SettlementInvariantError
  on unknown tx_type.
- tasks.py: pass `super_config` through to parse_settlement; assert
  non-None (m001 inserts the singleton at install time — None is an
  impossible state).
- partial-dispense ratio path in distribution.py is unchanged — still
  uses `settlement.platform_fee_sats / settlement.fee_sats` from the
  landed row, which is the right invariant (lock at landing) and
  independent of the per-direction config.

Tests:
- Rename `test_two_stage_split.py` → `test_operator_split_legs.py`.
  Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs
  (still-production fn) and TestPartialDispenseSplitRatio (inline ratio
  math in distribution.py).
- New `test_principal_based_fees.py`: pure-math tests for
  `split_principal_based` (six cases including a direct regression
  test pinning the pre-#38 bug at 240→3000 sats per 100k principal at
  3% super), plus parse_settlement directional dispatch tests
  (cash-in routes through cash-in fractions; cash-out through
  cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross-
  direction guard).

Migration verified end-to-end via container restart: super_config
columns post-m009 = id/super_fee_wallet_id/updated_at/
super_cash_in_fee_fraction/super_cash_out_fee_fraction (no
super_fee_fraction). dca_machines + dca_settlements gained the
expected new columns. 156/156 tests green.

Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes
the load-bearing super under-payment bug standalone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 11:24:09 +02:00
commit 1babdfbf06
8 changed files with 522 additions and 275 deletions

View 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