feat(v2): settlement distribution — three leg groups, super-fee write (P2)
After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.
distribution.py — three leg groups, in order:
1. super_fee leg:
platform_fee_sats → super_fee_wallet_id (if set)
skip + warn if super fee % > 0 but wallet not configured
2. operator_split legs:
operator_fee_sats sliced per the operator's commission_splits
ruleset (per-machine override or operator default)
skip + warn if operator has no ruleset configured
3. dca legs:
net_sats distributed proportionally to active flow-mode LPs at
this machine, each capped at the LP's remaining-fiat-balance-
in-sats (preserves the v1 sync-mismatch fix from PR #2)
skip if exchange_rate=0 (fallback path with missing rate)
Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.
Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.
calculations.py — extracted two pure helpers:
split_two_stage_commission(commission_sats, super_fee_pct)
Stage-1: super takes super_fee_pct (rounded); operator absorbs the
rounding remainder so platform + operator == commission_sats exactly.
allocate_operator_split_legs(operator_fee_sats, leg_pcts)
Stage-2: distributes the remainder across N legs per pct rules. Last
leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.
50 new tests cover the plan's verification scenario:
100 sats commission, super=30%, operator splits 50/30/20
→ super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).
views_api.py adds the super-only platform-fee write endpoint:
PUT /api/v1/dca/super-config (check_super_user)
This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.
72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).
Refs: aiolabs/satmachineadmin#9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
10b79ae900
commit
56be3e5c52
5 changed files with 559 additions and 4 deletions
|
|
@ -131,6 +131,70 @@ def calculate_distribution(
|
|||
return distributions
|
||||
|
||||
|
||||
def split_two_stage_commission(
|
||||
commission_sats: int, super_fee_pct: float
|
||||
) -> Tuple[int, int]:
|
||||
"""Stage-1 of the v2 commission split: super takes `super_fee_pct` of the
|
||||
total commission; the remainder is what the operator's own ruleset acts on.
|
||||
|
||||
Returns (platform_fee_sats, operator_fee_sats). Platform is rounded;
|
||||
operator absorbs the rounding remainder so platform_fee + operator_fee
|
||||
== commission_sats exactly.
|
||||
|
||||
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)
|
||||
"""
|
||||
if commission_sats <= 0:
|
||||
return 0, 0
|
||||
platform = round(commission_sats * super_fee_pct)
|
||||
platform = max(0, min(platform, commission_sats))
|
||||
operator = commission_sats - platform
|
||||
return platform, operator
|
||||
|
||||
|
||||
def allocate_operator_split_legs(
|
||||
operator_fee_sats: int, leg_pcts: list
|
||||
) -> list:
|
||||
"""Stage-2 of the v2 commission split: the operator's remainder is sliced
|
||||
across N leg wallets per `leg_pcts` (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 pcts sum to ~1.0). Returns
|
||||
a list of integer sat amounts in the same order as leg_pcts.
|
||||
|
||||
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_pcts:
|
||||
return []
|
||||
if operator_fee_sats <= 0:
|
||||
return [0] * len(leg_pcts)
|
||||
allocations: list = []
|
||||
remaining = operator_fee_sats
|
||||
for idx, pct in enumerate(leg_pcts):
|
||||
if idx == len(leg_pcts) - 1:
|
||||
allocations.append(remaining)
|
||||
else:
|
||||
amount = round(operator_fee_sats * float(pct))
|
||||
allocations.append(amount)
|
||||
remaining -= amount
|
||||
return allocations
|
||||
|
||||
|
||||
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
||||
"""
|
||||
Calculate exchange rate in sats per fiat unit.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue