fix(v2): partial-dispense preserves original split ratio (H6)
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) <noreply@anthropic.com>
This commit is contained in:
parent
ecef916dda
commit
00b8253dd3
2 changed files with 88 additions and 10 deletions
|
|
@ -34,7 +34,6 @@ from loguru import logger
|
||||||
from .calculations import (
|
from .calculations import (
|
||||||
allocate_operator_split_legs,
|
allocate_operator_split_legs,
|
||||||
calculate_distribution,
|
calculate_distribution,
|
||||||
split_two_stage_commission,
|
|
||||||
)
|
)
|
||||||
from .crud import (
|
from .crud import (
|
||||||
apply_partial_dispense,
|
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
|
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
|
6 of 10 bills), the operator confirms the actual amount dispensed and we
|
||||||
re-allocate the split against that partial gross. Sat amounts scale
|
re-allocate the split against that partial gross. Sat amounts scale
|
||||||
linearly, preserving the original commission ratio exactly; the two-stage
|
linearly, preserving the original commission ratio exactly. The two-stage
|
||||||
super/operator split is recomputed using the CURRENT super_fee_pct
|
super/operator split also scales by the *original* platform_fee_sats /
|
||||||
(super may have changed the rate since the original landed).
|
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.
|
Hard guard: refuses if any dca_payments leg has already completed.
|
||||||
Lightning payments can't be clawed back, so we won't try.
|
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_net = new_gross - new_commission
|
||||||
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
|
new_fiat = round(float(settlement.fiat_amount) * scale, 2)
|
||||||
|
|
||||||
# Re-stage-1 split using the CURRENT super_fee_pct.
|
# Re-derive the stage-1 split from the ORIGINAL ratio stored on this
|
||||||
super_config = await get_super_config()
|
# settlement row — NOT the current super_fee_pct. The contract was
|
||||||
super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0
|
# locked at landing; super raising or lowering the global rate after
|
||||||
new_platform, new_operator = split_two_stage_commission(
|
# the fact must not retroactively change this transaction's share.
|
||||||
new_commission, super_fee_pct
|
# 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(
|
memo = _build_partial_dispense_memo(
|
||||||
settlement,
|
settlement,
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,72 @@ class TestEndToEndScenarios:
|
||||||
# Operator has zero to distribute; both legs get zero.
|
# Operator has zero to distribute; both legs get zero.
|
||||||
assert legs == [0, 0]
|
assert legs == [0, 0]
|
||||||
assert platform + sum(legs) == 7965
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue