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
322
distribution.py
Normal file
322
distribution.py
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# Satoshi Machine v2 — settlement distribution (P2).
|
||||
#
|
||||
# Picks up a dca_settlements row with status='pending' and pays out the
|
||||
# three leg groups via LNbits internal transfers (create_invoice +
|
||||
# pay_invoice on the same instance auto-detect internal). All legs land
|
||||
# in dca_payments with the appropriate leg_type discriminator and inherit
|
||||
# the Payment.tag "satmachine:{machine_npub}" so LNbits payment-history
|
||||
# filters work natively.
|
||||
#
|
||||
# Leg order:
|
||||
# 1. super_fee — platform_fee_sats → super_fee_wallet_id (if set)
|
||||
# 2. operator_split — operator_fee_sats split per operator's rules
|
||||
# 3. dca — net_sats distributed proportionally to active LPs,
|
||||
# each leg capped at the LP's remaining fiat balance
|
||||
# (preserves the v1 sync-mismatch fix from PR #2)
|
||||
#
|
||||
# Atomicity: LN payments cannot be rolled back. We attempt each leg, record
|
||||
# success/failure per dca_payments row, and mark the settlement 'processed'
|
||||
# only when every leg completed. Any failure marks 'errored' with a message
|
||||
# but leaves the successful legs in place. Sats that don't get paid out
|
||||
# (failed legs, no LP coverage, missing super wallet) remain in the
|
||||
# machine's wallet — visible to the operator on the dashboard.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from loguru import logger
|
||||
|
||||
from .calculations import allocate_operator_split_legs, calculate_distribution
|
||||
from .crud import (
|
||||
create_dca_payment,
|
||||
get_client_balance_summary,
|
||||
get_effective_commission_splits,
|
||||
get_flow_mode_clients_for_machine,
|
||||
get_machine,
|
||||
get_settlement,
|
||||
get_super_config,
|
||||
mark_settlement_status,
|
||||
update_payment_status,
|
||||
)
|
||||
from .models import (
|
||||
CreateDcaPaymentData,
|
||||
DcaPayment,
|
||||
DcaSettlement,
|
||||
Machine,
|
||||
SuperConfig,
|
||||
)
|
||||
|
||||
PAYMENT_TAG_PREFIX = "satmachine"
|
||||
|
||||
|
||||
def _payment_tag(machine: Machine) -> str:
|
||||
return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}"
|
||||
|
||||
|
||||
async def process_settlement(settlement_id: str) -> None:
|
||||
"""Process a pending settlement end-to-end. Safe to invoke multiple
|
||||
times — the status='processed' guard skips already-processed rows."""
|
||||
settlement = await get_settlement(settlement_id)
|
||||
if settlement is None:
|
||||
logger.warning(f"distribution: settlement {settlement_id} not found")
|
||||
return
|
||||
if settlement.status != "pending":
|
||||
return
|
||||
machine = await get_machine(settlement.machine_id)
|
||||
if machine is None:
|
||||
logger.error(
|
||||
f"distribution: settlement {settlement_id} references missing "
|
||||
f"machine {settlement.machine_id}"
|
||||
)
|
||||
await mark_settlement_status(
|
||||
settlement_id, "errored", "machine missing"
|
||||
)
|
||||
return
|
||||
super_config = await get_super_config()
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
await _pay_super_fee(settlement, machine, super_config, errors)
|
||||
await _pay_operator_splits(settlement, machine, errors)
|
||||
await _pay_dca_distributions(settlement, machine, errors)
|
||||
except Exception as exc: # last-resort guard
|
||||
logger.exception("distribution: unexpected error processing settlement")
|
||||
errors.append(f"unexpected: {exc}")
|
||||
|
||||
if errors:
|
||||
await mark_settlement_status(
|
||||
settlement_id, "errored", "; ".join(errors)[:512]
|
||||
)
|
||||
else:
|
||||
await mark_settlement_status(settlement_id, "processed", None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Leg 1 — super fee
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _pay_super_fee(
|
||||
settlement: DcaSettlement,
|
||||
machine: Machine,
|
||||
super_config: SuperConfig | None,
|
||||
errors: List[str],
|
||||
) -> None:
|
||||
if settlement.platform_fee_sats <= 0:
|
||||
return
|
||||
if super_config is None or not super_config.super_fee_wallet_id:
|
||||
# Super has configured a fee but not a destination wallet — leave
|
||||
# the sats in the machine wallet and warn. The super needs to
|
||||
# configure their wallet before they can collect.
|
||||
logger.warning(
|
||||
f"distribution: super_fee_sats={settlement.platform_fee_sats} "
|
||||
f"left in machine wallet (super_fee_wallet_id not set)"
|
||||
)
|
||||
return
|
||||
await _pay_internal(
|
||||
settlement=settlement,
|
||||
machine=machine,
|
||||
leg_type="super_fee",
|
||||
client_id=None,
|
||||
destination_wallet_id=super_config.super_fee_wallet_id,
|
||||
amount_sats=settlement.platform_fee_sats,
|
||||
memo=f"satmachine super fee — {machine.name or machine.machine_npub[:12]}",
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Leg 2 — operator commission splits
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _pay_operator_splits(
|
||||
settlement: DcaSettlement,
|
||||
machine: Machine,
|
||||
errors: List[str],
|
||||
) -> None:
|
||||
if settlement.operator_fee_sats <= 0:
|
||||
return
|
||||
splits = await get_effective_commission_splits(
|
||||
machine.operator_user_id, machine.id
|
||||
)
|
||||
if not splits:
|
||||
logger.warning(
|
||||
f"distribution: operator_fee_sats={settlement.operator_fee_sats} "
|
||||
f"left in machine wallet (operator has no commission_splits ruleset "
|
||||
f"for machine {machine.id})"
|
||||
)
|
||||
return
|
||||
# Pure allocator handles the rounding rule (last leg absorbs remainder).
|
||||
leg_amounts = allocate_operator_split_legs(
|
||||
settlement.operator_fee_sats,
|
||||
[float(leg.pct) for leg in splits],
|
||||
)
|
||||
for idx, (leg, amount) in enumerate(zip(splits, leg_amounts)):
|
||||
if amount <= 0:
|
||||
continue
|
||||
label = leg.label or f"split-{idx + 1}"
|
||||
memo = (
|
||||
f"satmachine operator split — "
|
||||
f"{machine.name or machine.machine_npub[:12]} ({label})"
|
||||
)
|
||||
await _pay_internal(
|
||||
settlement=settlement,
|
||||
machine=machine,
|
||||
leg_type="operator_split",
|
||||
client_id=None,
|
||||
destination_wallet_id=leg.wallet_id,
|
||||
amount_sats=amount,
|
||||
memo=memo,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Leg 3 — DCA distribution to active LPs
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _pay_dca_distributions(
|
||||
settlement: DcaSettlement,
|
||||
machine: Machine,
|
||||
errors: List[str],
|
||||
) -> None:
|
||||
if settlement.net_sats <= 0:
|
||||
return
|
||||
if settlement.exchange_rate <= 0:
|
||||
# Fallback path with no exchange rate (bitSpire Payment.extra absent).
|
||||
# Without a rate we can't compute fiat balances → can't compute
|
||||
# proportional shares → leave net_sats in the machine wallet for
|
||||
# the operator to manually reconcile.
|
||||
logger.warning(
|
||||
f"distribution: net_sats={settlement.net_sats} left in machine "
|
||||
f"wallet (no exchange_rate; fallback path; see lamassu-next#44)"
|
||||
)
|
||||
return
|
||||
clients = await get_flow_mode_clients_for_machine(machine.id)
|
||||
if not clients:
|
||||
return
|
||||
# Build {client_id: remaining_fiat_balance} for proportional allocation.
|
||||
client_balances: dict[str, float] = {}
|
||||
for client in clients:
|
||||
summary = await get_client_balance_summary(client.id)
|
||||
if summary is None or summary.remaining_balance <= 0:
|
||||
continue
|
||||
client_balances[client.id] = summary.remaining_balance
|
||||
if not client_balances:
|
||||
return
|
||||
# Compute proportional sat allocations, then cap each at the client's
|
||||
# remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard).
|
||||
raw_allocations = calculate_distribution(
|
||||
base_amount_sats=settlement.net_sats,
|
||||
client_balances=client_balances,
|
||||
)
|
||||
capped_allocations: dict[str, int] = {}
|
||||
for client_id, raw_sats in raw_allocations.items():
|
||||
remaining_fiat = client_balances[client_id]
|
||||
cap_sats = int(remaining_fiat * float(settlement.exchange_rate))
|
||||
capped_allocations[client_id] = min(raw_sats, cap_sats)
|
||||
# Pay each capped allocation.
|
||||
client_by_id = {c.id: c for c in clients}
|
||||
for client_id, amount_sats in capped_allocations.items():
|
||||
if amount_sats <= 0:
|
||||
continue
|
||||
client = client_by_id[client_id]
|
||||
amount_fiat = round(amount_sats / float(settlement.exchange_rate), 2)
|
||||
memo = (
|
||||
f"DCA: {amount_sats} sats • {amount_fiat:.2f} {settlement.fiat_code}"
|
||||
)
|
||||
await _pay_internal(
|
||||
settlement=settlement,
|
||||
machine=machine,
|
||||
leg_type="dca",
|
||||
client_id=client.id,
|
||||
destination_wallet_id=client.wallet_id,
|
||||
amount_sats=amount_sats,
|
||||
amount_fiat=amount_fiat,
|
||||
exchange_rate=float(settlement.exchange_rate),
|
||||
memo=memo,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal transfer helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def _pay_internal(
|
||||
*,
|
||||
settlement: DcaSettlement,
|
||||
machine: Machine,
|
||||
leg_type: str,
|
||||
client_id: str | None,
|
||||
destination_wallet_id: str,
|
||||
amount_sats: int,
|
||||
memo: str,
|
||||
errors: List[str],
|
||||
amount_fiat: float | None = None,
|
||||
exchange_rate: float | None = None,
|
||||
) -> DcaPayment | None:
|
||||
"""Create an invoice on the destination wallet, pay it from the machine
|
||||
wallet, and record the leg in dca_payments. Returns the dca_payments row
|
||||
on success (including the failed case — the row stays for audit)."""
|
||||
tag = _payment_tag(machine)
|
||||
leg_row = await create_dca_payment(
|
||||
CreateDcaPaymentData(
|
||||
settlement_id=settlement.id,
|
||||
client_id=client_id,
|
||||
machine_id=machine.id,
|
||||
operator_user_id=machine.operator_user_id,
|
||||
leg_type=leg_type,
|
||||
destination_wallet_id=destination_wallet_id,
|
||||
destination_ln_address=None,
|
||||
amount_sats=amount_sats,
|
||||
amount_fiat=amount_fiat,
|
||||
exchange_rate=exchange_rate,
|
||||
transaction_time=datetime.now(),
|
||||
external_payment_hash=None,
|
||||
)
|
||||
)
|
||||
extra = {
|
||||
"satmachine_leg": leg_type,
|
||||
"satmachine_settlement_id": settlement.id,
|
||||
"satmachine_machine_npub": machine.machine_npub,
|
||||
}
|
||||
try:
|
||||
new_invoice = await create_invoice(
|
||||
wallet_id=destination_wallet_id,
|
||||
amount=float(amount_sats),
|
||||
internal=True,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
)
|
||||
if not new_invoice or not new_invoice.bolt11:
|
||||
await update_payment_status(
|
||||
leg_row.id, "failed", None, "create_invoice returned empty"
|
||||
)
|
||||
errors.append(f"{leg_type}: create_invoice empty")
|
||||
return leg_row
|
||||
paid = await pay_invoice(
|
||||
wallet_id=machine.wallet_id,
|
||||
payment_request=new_invoice.bolt11,
|
||||
description=memo,
|
||||
tag=tag,
|
||||
extra=extra,
|
||||
)
|
||||
await update_payment_status(
|
||||
leg_row.id, "completed", paid.payment_hash, None
|
||||
)
|
||||
return leg_row
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"distribution: {leg_type} leg failed "
|
||||
f"(settlement={settlement.id} amount={amount_sats}): {exc}"
|
||||
)
|
||||
await update_payment_status(leg_row.id, "failed", None, str(exc)[:512])
|
||||
errors.append(f"{leg_type}: {exc}")
|
||||
return leg_row
|
||||
Loading…
Add table
Add a link
Reference in a new issue