From 00b8253dd3b22c739d1b8570e7d25ffe634cc238 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 18:58:15 +0200 Subject: [PATCH] fix(v2): partial-dispense preserves original split ratio (H6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes H6 from #11. The partial-dispense recompute path was reading the CURRENT super_fee_pct via get_super_config() to re-derive the platform/ operator split. That breaks the "absolute fields are the source of truth" invariant the v2 schema was built around: if super raises (or lowers) the global rate between landing and partial-dispense, the operator's share would retroactively shift — without any notice to the operator and contrary to the original transaction's contract. Fix: re-derive new_platform from the *original* platform_fee_sats / commission_sats ratio stored on the settlement row, not from the current super_config. The contract was locked at landing; rate changes after the fact must not retroactively touch this transaction. Before: super_config = await get_super_config() super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 new_platform, new_operator = split_two_stage_commission( new_commission, super_fee_pct ) After: ratio = (settlement.platform_fee_sats / settlement.commission_sats if settlement.commission_sats > 0 else 0.0) new_platform = round(new_commission * ratio) new_platform = max(0, min(new_platform, new_commission)) new_operator = new_commission - new_platform Note: split_two_stage_commission at LANDING time (in bitspire.py) still uses the current super_fee_pct — that's correct, the rate at landing is the locked rate. Only the *recompute* path was wrong. Tests: TestPartialDispenseSplitRatio.test_plan_scenario_30pct_lands_then_partial: 100-sat commission @ 30% → partial to 50% → 15/35 (preserves ratio). test_super_changed_rate_doesnt_affect_existing_settlement: Super raises rate to 50% after a 30% landing; partial-dispense to 50% must keep the ORIGINAL ~30% platform share, not the new 50%. test_zero_original_commission_yields_zero_platform: edge case. test_invariant_sum_equals_new_commission: parametrised sum invariant. Also dropped the now-unused split_two_stage_commission import from distribution.py (still used in bitspire.py at landing time and by the test suite, just not in this file anymore). 54 / 54 tests pass. Refs: aiolabs/satmachineadmin#11 — H6 ✅ Remaining in #11: fix bundle 3 (dead-code purge), M and N items. Co-Authored-By: Claude Opus 4.7 (1M context) --- distribution.py | 29 ++++++++++----- tests/test_two_stage_split.py | 69 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/distribution.py b/distribution.py index 94e181d..7f47c7e 100644 --- a/distribution.py +++ b/distribution.py @@ -34,7 +34,6 @@ from loguru import logger from .calculations import ( allocate_operator_split_legs, calculate_distribution, - split_two_stage_commission, ) from .crud import ( apply_partial_dispense, @@ -268,9 +267,12 @@ async def apply_partial_dispense_and_redistribute( When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after 6 of 10 bills), the operator confirms the actual amount dispensed and we re-allocate the split against that partial gross. Sat amounts scale - linearly, preserving the original commission ratio exactly; the two-stage - super/operator split is recomputed using the CURRENT super_fee_pct - (super may have changed the rate since the original landed). + linearly, preserving the original commission ratio exactly. The two-stage + super/operator split also scales by the *original* platform_fee_sats / + commission_sats ratio rather than re-reading current super_fee_pct — + this honors the "absolute fields are the source of truth" invariant + even when super has changed the global rate since the settlement landed + (closes #11 H6). Hard guard: refuses if any dca_payments leg has already completed. Lightning payments can't be clawed back, so we won't try. @@ -301,12 +303,19 @@ async def apply_partial_dispense_and_redistribute( new_net = new_gross - new_commission new_fiat = round(float(settlement.fiat_amount) * scale, 2) - # Re-stage-1 split using the CURRENT super_fee_pct. - super_config = await get_super_config() - super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 - new_platform, new_operator = split_two_stage_commission( - new_commission, super_fee_pct - ) + # Re-derive the stage-1 split from the ORIGINAL ratio stored on this + # settlement row — NOT the current super_fee_pct. The contract was + # locked at landing; super raising or lowering the global rate after + # the fact must not retroactively change this transaction's share. + # Operator absorbs the rounding remainder so platform + operator + # == new_commission exactly. + if settlement.commission_sats > 0: + ratio = settlement.platform_fee_sats / settlement.commission_sats + else: + ratio = 0.0 + new_platform = round(new_commission * ratio) + new_platform = max(0, min(new_platform, new_commission)) + new_operator = new_commission - new_platform memo = _build_partial_dispense_memo( settlement, diff --git a/tests/test_two_stage_split.py b/tests/test_two_stage_split.py index 71490c6..beff376 100644 --- a/tests/test_two_stage_split.py +++ b/tests/test_two_stage_split.py @@ -142,3 +142,72 @@ class TestEndToEndScenarios: # 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_pct. + + 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_commission, original_platform_fee, new_commission): + """Mirror of the ratio math in apply_partial_dispense_and_redistribute.""" + if original_commission > 0: + ratio = original_platform_fee / original_commission + else: + ratio = 0.0 + new_platform = round(new_commission * ratio) + new_platform = max(0, min(new_platform, new_commission)) + new_operator = new_commission - new_platform + return new_platform, new_operator + + def test_plan_scenario_30pct_lands_then_partial(self): + # Landed at super_fee_pct=30%: 100-sat commission → 30 / 70. + # Partial-dispense to 50% gross → new_commission = 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_pct=30% (commission 7965, platform 2390). + # Super then raises rate to 50% globally. Operator partial-dispenses + # to 50% gross → new_commission = 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_commission_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_commission(self): + # Random-ish parameter sweep over realistic values. + cases = [ + (100, 30, 50), + (100, 0, 50), # original platform_fee was 0 (super_pct=0) + (100, 100, 50), # original platform_fee was 100 (super_pct=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