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>
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
"""
|
||
Pure calculation functions for DCA transaction processing.
|
||
|
||
These functions have no external dependencies (no lnbits, no database)
|
||
and can be easily tested in isolation.
|
||
|
||
What's intentionally NOT here (deleted 2026-05-26):
|
||
- `calculate_commission` (back-derive principal+fee from a gross-with-
|
||
commission-baked-in wire amount). Lamassu-era reverse-derivation;
|
||
obsolete since bitSpire stamps `principal_sats` AND `fee_sats`
|
||
directly on Payment.extra per aiolabs/lamassu-next#44.
|
||
- `calculate_exchange_rate` (principal / fiat_amount). bitSpire stamps
|
||
`exchange_rate` directly on Payment.extra too. Not used in production.
|
||
"""
|
||
|
||
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],
|
||
min_balance_threshold: float = 0.01
|
||
) -> Dict[str, int]:
|
||
"""
|
||
Calculate proportional distribution of sats to clients based on their fiat balances.
|
||
|
||
Uses proportional allocation with remainder distribution to ensure
|
||
the total distributed equals exactly the base amount.
|
||
|
||
Args:
|
||
base_amount_sats: Total sats to distribute (after commission)
|
||
client_balances: Dict of {client_id: remaining_balance_fiat}
|
||
min_balance_threshold: Minimum balance to be included (default 0.01)
|
||
|
||
Returns:
|
||
Dict of {client_id: allocated_sats}
|
||
|
||
Example:
|
||
>>> calculate_distribution(100000, {"a": 500.0, "b": 500.0})
|
||
{"a": 50000, "b": 50000}
|
||
"""
|
||
# Filter out clients with balance below threshold
|
||
active_balances = {
|
||
client_id: balance
|
||
for client_id, balance in client_balances.items()
|
||
if balance >= min_balance_threshold
|
||
}
|
||
|
||
if not active_balances:
|
||
return {}
|
||
|
||
total_balance = sum(active_balances.values())
|
||
|
||
if total_balance == 0:
|
||
return {}
|
||
|
||
# First pass: calculate base allocations and track for remainder distribution
|
||
client_calculations = []
|
||
distributed_sats = 0
|
||
|
||
for client_id, balance in active_balances.items():
|
||
proportion = balance / total_balance
|
||
exact_share = base_amount_sats * proportion
|
||
allocated_sats = round(exact_share)
|
||
|
||
client_calculations.append({
|
||
'client_id': client_id,
|
||
'proportion': proportion,
|
||
'exact_share': exact_share,
|
||
'allocated_sats': allocated_sats,
|
||
})
|
||
distributed_sats += allocated_sats
|
||
|
||
# Handle remainder due to rounding
|
||
remainder = base_amount_sats - distributed_sats
|
||
|
||
if remainder != 0:
|
||
# Sort by largest fractional remainder to distribute fairly
|
||
client_calculations.sort(
|
||
key=lambda x: x['exact_share'] - x['allocated_sats'],
|
||
reverse=True
|
||
)
|
||
|
||
# Distribute remainder one sat at a time
|
||
for i in range(abs(remainder)):
|
||
idx = i % len(client_calculations)
|
||
if remainder > 0:
|
||
client_calculations[idx]['allocated_sats'] += 1
|
||
else:
|
||
client_calculations[idx]['allocated_sats'] -= 1
|
||
|
||
# Build final distributions dict
|
||
distributions = {
|
||
calc['client_id']: calc['allocated_sats']
|
||
for calc in client_calculations
|
||
}
|
||
|
||
return distributions
|
||
|
||
|
||
def split_principal_based(
|
||
principal_sats: int,
|
||
super_frac: float,
|
||
operator_frac: float,
|
||
) -> Tuple[int, int]:
|
||
"""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). 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_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_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 = max(0, round(principal_sats * super_frac))
|
||
operator = max(0, round(principal_sats * operator_frac))
|
||
return platform, operator
|
||
|
||
|
||
def allocate_operator_split_legs(
|
||
operator_fee_sats: int, leg_fractions: list
|
||
) -> list:
|
||
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
|
||
across N leg wallets per `leg_fractions` (each in [0, 1], sum should
|
||
equal 1.0).
|
||
|
||
The last leg absorbs the rounding remainder so the sum of allocations
|
||
exactly equals operator_fee_sats (assuming fractions sum to ~1.0).
|
||
Returns a list of integer sat amounts in the same order as leg_fractions.
|
||
|
||
Examples:
|
||
>>> allocate_operator_split_legs(70, [0.5, 0.3, 0.2])
|
||
[35, 21, 14]
|
||
>>> allocate_operator_split_legs(5575, [0.5, 0.3, 0.2])
|
||
[2787, 1672, 1116]
|
||
>>> allocate_operator_split_legs(100, [1.0])
|
||
[100]
|
||
>>> allocate_operator_split_legs(0, [0.5, 0.5])
|
||
[0, 0]
|
||
"""
|
||
if not leg_fractions:
|
||
return []
|
||
if operator_fee_sats <= 0:
|
||
return [0] * len(leg_fractions)
|
||
for f in leg_fractions:
|
||
if not (0.0 <= float(f) <= 1.0):
|
||
raise ValueError(
|
||
f"every leg fraction must be in [0, 1], got {f}"
|
||
)
|
||
allocations: list = []
|
||
remaining = operator_fee_sats
|
||
for idx, fraction in enumerate(leg_fractions):
|
||
if idx == len(leg_fractions) - 1:
|
||
allocations.append(remaining)
|
||
else:
|
||
amount = round(operator_fee_sats * float(fraction))
|
||
allocations.append(amount)
|
||
remaining -= amount
|
||
return allocations
|
||
|
||
|