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

@ -106,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