fix(v2): decouple listener + skipped-leg audit (fix bundle 2)

Closes H4, H5, M8 from the v2-bitspire review (omnibus follow-up #11).

H4 — Decouple invoice listener from distribution.
  tasks._handle_payment now spawns process_settlement on a background
  task instead of awaiting it. The LNbits invoice queue is shared
  across every extension on the node; under load (a machine with 50
  LPs, a stalled internal payment, etc.) the previous synchronous path
  could freeze the queue for everyone. Concurrency is safe because
  fix bundle 1's claim_settlement_for_processing already prevents
  double-processing on listener re-fires.

  RUF006 fix: hold strong refs to in-flight tasks via a module-level
  set so the GC doesn't collect them mid-flight (asyncio.create_task
  only weakly references its task). Tasks self-clean via
  add_done_callback(set.discard).

H5 + M8 — Skipped-leg audit rows for stranded sats.
  Previously, four paths in distribution.py logged a warning and left
  sats in the machine wallet, marking the settlement 'processed' with
  no row-level visibility into where the un-paid sats sit:
    1. _pay_super_fee: super_fee_pct > 0 but super_fee_wallet_id unset
    2. _pay_operator_splits: no commission ruleset (default + override)
    3. _pay_dca_distributions: exchange_rate = 0 (fallback path)
    4. _pay_dca_distributions: no eligible LPs with positive balance
  Plus a fifth case the review didn't enumerate but is the same shape:
    5. _pay_dca_distributions: no flow-mode LPs at the machine at all

  Each now writes a dca_payments row with status='skipped', the
  intended leg_type (super_fee / operator_split / dca), the stranded
  amount in amount_sats, and a human-readable error_message explaining
  why. New _record_skipped_leg helper consolidates the pattern.

  This makes stranded sats visible in:
    - The machine detail dialog's settlements rows (the legs are
      filtered into the audit blob alongside completed/failed legs)
    - The payments CSV export
    - GET /api/v1/dca/payments?leg_type=...

  'skipped' is a documented leg-status value now (alongside pending /
  completed / failed / voided / refunded) — no schema change since
  status is TEXT.

Knock-on fix: void_open_legs_for_settlement (used by partial-dispense
recompute) now also includes status='skipped' in its WHERE clause so a
re-run doesn't double-count the audit rows from a prior attempt.

72/72 tests still pass. Lint clean.

Refs: aiolabs/satmachineadmin#11 — fix bundle 2 
Remaining in #11: H6 (partial-dispense split ratio) + fix bundle 3
(dead-code purge) + the M and N items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:49:16 +02:00
commit ecef916dda
4 changed files with 118 additions and 28 deletions

View file

@ -69,6 +69,48 @@ def _payment_tag(machine: Machine) -> str:
return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}"
async def _record_skipped_leg(
settlement: DcaSettlement,
machine: Machine,
leg_type: str,
amount_sats: int,
reason: str,
client_id: str | None = None,
) -> None:
"""Audit row for sats intentionally left in the machine wallet.
Distinct from 'failed' (which means pay_invoice errored). 'skipped' means
we never attempted the pay by design, because some prerequisite was
missing (super wallet not configured, no operator ruleset, no exchange
rate, no eligible LPs). Operator sees these in payment history and on
the settlement detail blob; the audit trail explains where un-paid
sats are sitting.
"""
if amount_sats <= 0:
return
leg = 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=None,
destination_ln_address=None,
amount_sats=amount_sats,
amount_fiat=None,
exchange_rate=None,
transaction_time=datetime.now(timezone.utc),
external_payment_hash=None,
)
)
await update_payment_status(leg.id, "skipped", None, reason[:512])
logger.info(
f"distribution: skipped {leg_type} leg "
f"({amount_sats} sats) — {reason}"
)
def _resolve_partial_dispense_gross(
settlement: DcaSettlement, data: PartialDispenseData
) -> int:
@ -361,11 +403,13 @@ async def _pay_super_fee(
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)"
# the sats in the machine wallet and record a skipped audit row.
# The super needs to configure their wallet before they can collect.
await _record_skipped_leg(
settlement, machine,
leg_type="super_fee",
amount_sats=settlement.platform_fee_sats,
reason="super_fee_wallet_id not configured by LNbits super",
)
return
await _pay_internal(
@ -396,10 +440,14 @@ async def _pay_operator_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})"
await _record_skipped_leg(
settlement, machine,
leg_type="operator_split",
amount_sats=settlement.operator_fee_sats,
reason=(
"operator has no commission_splits ruleset for this machine "
"(neither per-machine override nor operator default)"
),
)
return
# Pure allocator handles the rounding rule (last leg absorbs remainder).
@ -443,14 +491,25 @@ async def _pay_dca_distributions(
# 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)"
# manual reconciliation. Audit row makes the strand visible.
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason=(
"no exchange_rate on settlement (bitSpire fallback path; "
"see aiolabs/lamassu-next#44)"
),
)
return
clients = await get_flow_mode_clients_for_machine(machine.id)
if not clients:
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason="no active flow-mode LPs registered at this machine",
)
return
# Build {client_id: remaining_fiat_balance} for proportional allocation.
client_balances: dict[str, float] = {}
@ -460,6 +519,15 @@ async def _pay_dca_distributions(
continue
client_balances[client.id] = summary.remaining_balance
if not client_balances:
await _record_skipped_leg(
settlement, machine,
leg_type="dca",
amount_sats=settlement.net_sats,
reason=(
"no LP has remaining-fiat-balance > 0 — all confirmed deposits "
"already paid out"
),
)
return
# Compute proportional sat allocations, then cap each at the client's
# remaining-fiat-balance-in-sats (the v1 sync-mismatch safeguard).