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
28
views_api.py
28
views_api.py
|
|
@ -9,7 +9,7 @@ from http import HTTPStatus
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.decorators import check_super_user, check_user_exists
|
||||
|
||||
from .crud import (
|
||||
create_machine,
|
||||
|
|
@ -22,6 +22,7 @@ from .crud import (
|
|||
get_settlements_for_operator,
|
||||
get_super_config,
|
||||
update_machine,
|
||||
update_super_config,
|
||||
)
|
||||
from .models import (
|
||||
CreateMachineData,
|
||||
|
|
@ -30,6 +31,7 @@ from .models import (
|
|||
Machine,
|
||||
SuperConfig,
|
||||
UpdateMachineData,
|
||||
UpdateSuperConfigData,
|
||||
)
|
||||
|
||||
satmachineadmin_api_router = APIRouter()
|
||||
|
|
@ -155,7 +157,7 @@ async def api_list_payments(
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Super config — read-only at this phase. Super-only write endpoint lands in P2.
|
||||
# Super config — operators read; super (LNbits instance admin) writes.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
|
@ -167,8 +169,7 @@ async def api_get_super_config(
|
|||
) -> SuperConfig:
|
||||
"""Returns the platform-fee config so operators can display it as a
|
||||
read-only line item in their UI. The fee is set by the LNbits super
|
||||
instance-wide; operators see it but can't change it (write endpoint
|
||||
protected by check_super_user, landing in P2)."""
|
||||
instance-wide; operators see it but can't change it."""
|
||||
config = await get_super_config()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -177,6 +178,25 @@ async def api_get_super_config(
|
|||
return config
|
||||
|
||||
|
||||
@satmachineadmin_api_router.put(
|
||||
"/api/v1/dca/super-config", response_model=SuperConfig
|
||||
)
|
||||
async def api_update_super_config(
|
||||
data: UpdateSuperConfigData,
|
||||
_user: User = Depends(check_super_user),
|
||||
) -> SuperConfig:
|
||||
"""Super-only: set the platform fee % charged on every operator's
|
||||
commission, plus the destination wallet for collecting it. The fee is
|
||||
enforced before the operator's own commission_splits ruleset fires
|
||||
(see distribution.process_settlement)."""
|
||||
config = await update_super_config(data)
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Catch-all stub for endpoints not yet implemented (clients, deposits,
|
||||
# commission splits, partial-tx, balance-settle, super-config write). Each
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue